Initial Development #1
@ -12,6 +12,8 @@
|
|||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<kotlin.code.style>official</kotlin.code.style>
|
<kotlin.code.style>official</kotlin.code.style>
|
||||||
<kotlin.compiler.jvmTarget>1.8</kotlin.compiler.jvmTarget>
|
<kotlin.compiler.jvmTarget>1.8</kotlin.compiler.jvmTarget>
|
||||||
|
<kotlin.version>2.1.0</kotlin.version>
|
||||||
|
<serialization.version>1.8.0</serialization.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<repositories>
|
<repositories>
|
||||||
@ -28,7 +30,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.jetbrains.kotlin</groupId>
|
<groupId>org.jetbrains.kotlin</groupId>
|
||||||
<artifactId>kotlin-maven-plugin</artifactId>
|
<artifactId>kotlin-maven-plugin</artifactId>
|
||||||
<version>2.1.0</version>
|
<version>${kotlin.version}</version>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
<id>compile</id>
|
<id>compile</id>
|
||||||
@ -45,6 +47,18 @@
|
|||||||
</goals>
|
</goals>
|
||||||
</execution>
|
</execution>
|
||||||
</executions>
|
</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>
|
||||||
<plugin>
|
<plugin>
|
||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
@ -75,18 +89,23 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.jetbrains.kotlin</groupId>
|
<groupId>org.jetbrains.kotlin</groupId>
|
||||||
<artifactId>kotlin-test-junit5</artifactId>
|
<artifactId>kotlin-test-junit5</artifactId>
|
||||||
<version>2.1.0</version>
|
<version>${kotlin.version}</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.jetbrains.kotlin</groupId>
|
<groupId>org.jetbrains.kotlin</groupId>
|
||||||
<artifactId>kotlin-stdlib</artifactId>
|
<artifactId>kotlin-stdlib</artifactId>
|
||||||
<version>2.1.0</version>
|
<version>${kotlin.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.jetbrains.kotlin</groupId>
|
<groupId>org.jetbrains.kotlin</groupId>
|
||||||
<artifactId>kotlin-reflect</artifactId>
|
<artifactId>kotlin-reflect</artifactId>
|
||||||
<version>2.0.20</version>
|
<version>${kotlin.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jetbrains.kotlinx</groupId>
|
||||||
|
<artifactId>kotlinx-serialization-json</artifactId>
|
||||||
|
<version>${serialization.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
@ -54,19 +54,13 @@ enum class AutoId {
|
|||||||
if (id == null) throw IllegalArgumentException("$idProp not found in document")
|
if (id == null) throw IllegalArgumentException("$idProp not found in document")
|
||||||
|
|
||||||
if (strategy == NUMBER) {
|
if (strategy == NUMBER) {
|
||||||
if (id.returnType == Byte::class.createType()) {
|
return when (id.returnType) {
|
||||||
return id.call(document) == 0.toByte()
|
Byte::class.createType() -> id.call(document) == 0.toByte()
|
||||||
|
Short::class.createType() -> id.call(document) == 0.toShort()
|
||||||
|
Int::class.createType() -> id.call(document) == 0
|
||||||
|
Long::class.createType() -> id.call(document) == 0.toLong()
|
||||||
|
else -> throw IllegalArgumentException("$idProp was not a number; cannot auto-generate number ID")
|
||||||
}
|
}
|
||||||
if (id.returnType == Short::class.createType()) {
|
|
||||||
return id.call(document) == 0.toShort()
|
|
||||||
}
|
|
||||||
if (id.returnType == Int::class.createType()) {
|
|
||||||
return id.call(document) == 0
|
|
||||||
}
|
|
||||||
if (id.returnType == Long::class.createType()) {
|
|
||||||
return id.call(document) == 0.toLong()
|
|
||||||
}
|
|
||||||
throw IllegalArgumentException("$idProp was not a number; cannot auto-generate number ID")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id.returnType == String::class.createType()) {
|
if (id.returnType == String::class.createType()) {
|
||||||
|
@ -1,8 +1,21 @@
|
|||||||
package solutions.bitbadger.documents.common
|
package solutions.bitbadger.documents.common
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.sql.Connection
|
||||||
|
import java.sql.DriverManager
|
||||||
|
|
||||||
object Configuration {
|
object Configuration {
|
||||||
|
|
||||||
// TODO: var jsonOpts = Json { some cool options }
|
/**
|
||||||
|
* JSON serializer; replace to configure with non-default options
|
||||||
|
*
|
||||||
|
* The default sets `encodeDefaults` to `true` and `explicitNulls` to `false`; see
|
||||||
|
* https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/json.md for all configuration options
|
||||||
|
*/
|
||||||
|
var json = Json {
|
||||||
|
encodeDefaults = true
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
|
||||||
/** The field in which a document's ID is stored */
|
/** The field in which a document's ID is stored */
|
||||||
var idField = "id"
|
var idField = "id"
|
||||||
@ -12,4 +25,40 @@ object Configuration {
|
|||||||
|
|
||||||
/** The length of automatic random hex character string */
|
/** The length of automatic random hex character string */
|
||||||
var idStringLength = 16
|
var idStringLength = 16
|
||||||
|
|
||||||
|
/** The connection string for the JDBC connection */
|
||||||
|
var connectionString: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a new connection to the configured database
|
||||||
|
*
|
||||||
|
* @return A new connection to the configured database
|
||||||
|
* @throws IllegalArgumentException If the connection string is not set before calling this
|
||||||
|
*/
|
||||||
|
fun dbConn(): Connection {
|
||||||
|
if (connectionString == null) {
|
||||||
|
throw IllegalArgumentException("Please provide a connection string before attempting data access")
|
||||||
|
}
|
||||||
|
return DriverManager.getConnection(connectionString)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dialectValue: Dialect? = null
|
||||||
|
|
||||||
|
/** The dialect in use */
|
||||||
|
val dialect: Dialect
|
||||||
|
get() {
|
||||||
|
if (dialectValue == null) {
|
||||||
|
if (connectionString == null) {
|
||||||
|
throw IllegalArgumentException("Please provide a connection string before attempting data access")
|
||||||
|
}
|
||||||
|
val it = connectionString!!
|
||||||
|
dialectValue = when {
|
||||||
|
it.contains("sqlite") -> Dialect.SQLITE
|
||||||
|
it.contains("postgresql") -> Dialect.POSTGRESQL
|
||||||
|
else -> throw IllegalArgumentException("Cannot determine dialect from [$it]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dialectValue!!
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
40
src/common/src/main/kotlin/ConnectionExtensions.kt
Normal file
40
src/common/src/main/kotlin/ConnectionExtensions.kt
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package solutions.bitbadger.documents.common
|
||||||
|
|
||||||
|
import java.sql.Connection
|
||||||
|
import java.sql.ResultSet
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query that returns a list of results
|
||||||
|
*
|
||||||
|
* @param query The query to retrieve the results
|
||||||
|
* @param parameters Parameters to use for the query
|
||||||
|
* @param mapFunc The mapping function between the document and the domain item
|
||||||
|
* @return A list of results for the given query
|
||||||
|
*/
|
||||||
|
inline fun <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() }
|
||||||
|
}
|
9
src/common/src/main/kotlin/DocumentException.kt
Normal file
9
src/common/src/main/kotlin/DocumentException.kt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package solutions.bitbadger.documents.common
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An exception caused by invalid operations in the document library
|
||||||
|
*
|
||||||
|
* @param message The message for the exception
|
||||||
|
* @param cause The underlying exception (optional)
|
||||||
|
*/
|
||||||
|
class DocumentException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
15
src/common/src/main/kotlin/Parameter.kt
Normal file
15
src/common/src/main/kotlin/Parameter.kt
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package solutions.bitbadger.documents.common
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parameter to use for a query
|
||||||
|
*
|
||||||
|
* @property name The name of the parameter (prefixed with a colon)
|
||||||
|
* @property type The type of this parameter
|
||||||
|
* @property value The value of the parameter
|
||||||
|
*/
|
||||||
|
class Parameter<T>(val name: String, val type: ParameterType, val value: T) {
|
||||||
|
init {
|
||||||
|
if (!name.startsWith(':') && !name.startsWith('@'))
|
||||||
|
throw DocumentException("Name must start with : or @ ($name)")
|
||||||
|
}
|
||||||
|
}
|
13
src/common/src/main/kotlin/ParameterType.kt
Normal file
13
src/common/src/main/kotlin/ParameterType.kt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package solutions.bitbadger.documents.common
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The types of parameters supported by the document library
|
||||||
|
*/
|
||||||
|
enum class ParameterType {
|
||||||
|
/** The parameter value is some sort of number (`Byte`, `Short`, `Int`, or `Long`) */
|
||||||
|
NUMBER,
|
||||||
|
/** The parameter value is a string */
|
||||||
|
STRING,
|
||||||
|
/** The parameter should be JSON-encoded */
|
||||||
|
JSON,
|
||||||
|
}
|
83
src/common/src/main/kotlin/Parameters.kt
Normal file
83
src/common/src/main/kotlin/Parameters.kt
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package solutions.bitbadger.documents.common
|
||||||
|
|
||||||
|
import java.sql.Connection
|
||||||
|
import java.sql.PreparedStatement
|
||||||
|
import java.sql.SQLException
|
||||||
|
import java.sql.Types
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Functions to assist with the creation and implementation of parameters for SQL queries
|
||||||
|
*
|
||||||
|
* @author Daniel J. Summers <daniel@bitbadger.solutions>
|
||||||
|
*/
|
||||||
|
object Parameters {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the parameter names in the query with question marks
|
||||||
|
*
|
||||||
|
* @param query The query with named placeholders
|
||||||
|
* @param parameters The parameters for the query
|
||||||
|
* @return The query, with name parameters changed to `?`s
|
||||||
|
*/
|
||||||
|
fun replaceNamesInQuery(query: String, parameters: Collection<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -146,21 +146,20 @@ object Query {
|
|||||||
} else {
|
} else {
|
||||||
Pair<Field<*>, String?>(it, null)
|
Pair<Field<*>, String?>(it, null)
|
||||||
}
|
}
|
||||||
val path =
|
val path = when {
|
||||||
if (field.name.startsWith("n:")) {
|
field.name.startsWith("n:") -> Field.named(field.name.substring(2)).let { fld ->
|
||||||
val fld = Field.named(field.name.substring(2))
|
|
||||||
when (dialect) {
|
when (dialect) {
|
||||||
Dialect.POSTGRESQL -> "(${fld.path(dialect)})::numeric"
|
Dialect.POSTGRESQL -> "(${fld.path(dialect)})::numeric"
|
||||||
Dialect.SQLITE -> fld.path(dialect)
|
Dialect.SQLITE -> fld.path(dialect)
|
||||||
}
|
}
|
||||||
} else if (field.name.startsWith("i:")) {
|
}
|
||||||
val p = Field.named(field.name.substring(2)).path(dialect)
|
field.name.startsWith("i:") -> Field.named(field.name.substring(2)).path(dialect).let { p ->
|
||||||
when (dialect) {
|
when (dialect) {
|
||||||
Dialect.POSTGRESQL -> "LOWER($p)"
|
Dialect.POSTGRESQL -> "LOWER($p)"
|
||||||
Dialect.SQLITE -> "$p COLLATE NOCASE"
|
Dialect.SQLITE -> "$p COLLATE NOCASE"
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
field.path(dialect)
|
else -> field.path(dialect)
|
||||||
}
|
}
|
||||||
"$path${direction ?: ""}"
|
"$path${direction ?: ""}"
|
||||||
}
|
}
|
||||||
|
75
src/common/src/main/kotlin/Results.kt
Normal file
75
src/common/src/main/kotlin/Results.kt
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
package solutions.bitbadger.documents.common
|
||||||
|
|
||||||
|
import java.sql.PreparedStatement
|
||||||
|
import java.sql.ResultSet
|
||||||
|
import java.sql.SQLException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper functions for handling results
|
||||||
|
*/
|
||||||
|
object Results {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a domain item from a document, specifying the field in which the document is found
|
||||||
|
*
|
||||||
|
* @param field The field name containing the JSON document
|
||||||
|
* @param rs A `ResultSet` set to the row with the document to be constructed
|
||||||
|
* @return The constructed domain item
|
||||||
|
*/
|
||||||
|
inline fun <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
|
||||||
|
}
|
||||||
|
}
|
34
src/common/src/test/kotlin/ParameterTest.kt
Normal file
34
src/common/src/test/kotlin/ParameterTest.kt
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package solutions.bitbadger.documents.common
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class ParameterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Construction with colon-prefixed name")
|
||||||
|
fun ctorWithColon() {
|
||||||
|
val p = Parameter(":test", ParameterType.STRING, "ABC")
|
||||||
|
assertEquals(":test", p.name, "Parameter name was incorrect")
|
||||||
|
assertEquals(ParameterType.STRING, p.type, "Parameter type was incorrect")
|
||||||
|
assertEquals("ABC", p.value, "Parameter value was incorrect")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Construction with at-sign-prefixed name")
|
||||||
|
fun ctorWithAtSign() {
|
||||||
|
val p = Parameter("@yo", ParameterType.NUMBER, null)
|
||||||
|
assertEquals("@yo", p.name, "Parameter name was incorrect")
|
||||||
|
assertEquals(ParameterType.NUMBER, p.type, "Parameter type was incorrect")
|
||||||
|
assertNull(p.value, "Parameter value was incorrect")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Construction fails with incorrect prefix")
|
||||||
|
fun ctorFailsForPrefix() {
|
||||||
|
assertThrows<DocumentException> { Parameter("it", ParameterType.JSON, "") }
|
||||||
|
}
|
||||||
|
}
|
18
src/common/src/test/kotlin/ParametersTest.kt
Normal file
18
src/common/src/test/kotlin/ParametersTest.kt
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package solutions.bitbadger.documents.common
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class ParametersTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("replaceNamesInQuery replaces successfully")
|
||||||
|
fun replaceNamesInQuery() {
|
||||||
|
val parameters = listOf(Parameter(":data", ParameterType.JSON, "{}"),
|
||||||
|
Parameter(":data_ext", ParameterType.STRING, ""))
|
||||||
|
val query = "SELECT data, data_ext FROM tbl WHERE data = :data AND data_ext = :data_ext AND more_data = :data"
|
||||||
|
assertEquals("SELECT data, data_ext FROM tbl WHERE data = ? AND data_ext = ? AND more_data = ?",
|
||||||
|
Parameters.replaceNamesInQuery(query, parameters), "Parameters not replaced correctly")
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
package solutions.bitbadger.documents.sqlite
|
package solutions.bitbadger.documents.sqlite
|
||||||
|
|
||||||
import solutions.bitbadger.documents.common.*
|
import solutions.bitbadger.documents.common.*
|
||||||
import solutions.bitbadger.documents.common.Configuration
|
import solutions.bitbadger.documents.common.Configuration as BaseConfig;
|
||||||
import solutions.bitbadger.documents.common.Query
|
import solutions.bitbadger.documents.common.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,13 +30,16 @@ object Query {
|
|||||||
}
|
}
|
||||||
Op.IN -> {
|
Op.IN -> {
|
||||||
val p = name.derive(it.parameterName)
|
val p = name.derive(it.parameterName)
|
||||||
val values = comp.value as List<*>
|
val values = comp.value as Collection<*>
|
||||||
val paramNames = values.indices.joinToString(", ") { idx -> "${p}_$idx" }
|
val paramNames = values.indices.joinToString(", ") { idx -> "${p}_$idx" }
|
||||||
"${it.path(Dialect.SQLITE, FieldFormat.SQL)} ${comp.op.sql} ($paramNames)"
|
"${it.path(Dialect.SQLITE, FieldFormat.SQL)} ${comp.op.sql} ($paramNames)"
|
||||||
}
|
}
|
||||||
Op.IN_ARRAY -> {
|
Op.IN_ARRAY -> {
|
||||||
val p = name.derive(it.parameterName)
|
val p = name.derive(it.parameterName)
|
||||||
val (table, values) = comp.value as Pair<String, List<*>>
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val tableAndValues = comp.value as? Pair<String, Collection<*>>
|
||||||
|
?: throw IllegalArgumentException("InArray field invalid")
|
||||||
|
val (table, values) = tableAndValues
|
||||||
val paramNames = values.indices.joinToString(", ") { idx -> "${p}_$idx" }
|
val paramNames = values.indices.joinToString(", ") { idx -> "${p}_$idx" }
|
||||||
"EXISTS (SELECT 1 FROM json_each($table.data, '$.${it.name}') WHERE value IN ($paramNames)"
|
"EXISTS (SELECT 1 FROM json_each($table.data, '$.${it.name}') WHERE value IN ($paramNames)"
|
||||||
}
|
}
|
||||||
@ -54,7 +57,8 @@ object Query {
|
|||||||
* @return A `WHERE` clause fragment identifying a document by its ID
|
* @return A `WHERE` clause fragment identifying a document by its ID
|
||||||
*/
|
*/
|
||||||
fun <TKey> whereById(docId: TKey): String =
|
fun <TKey> whereById(docId: TKey): String =
|
||||||
whereByFields(FieldMatch.ANY, listOf(Field.equal(Configuration.idField, docId).withParameterName(":id")))
|
whereByFields(FieldMatch.ANY,
|
||||||
|
listOf(Field.equal(BaseConfig.idField, docId).withParameterName(":id")))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an `UPDATE` statement to patch documents
|
* Create an `UPDATE` statement to patch documents
|
||||||
|
99
src/sqlite/src/test/kotlin/QueryTest.kt
Normal file
99
src/sqlite/src/test/kotlin/QueryTest.kt
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
package solutions.bitbadger.documents.sqlite
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import solutions.bitbadger.documents.common.Field
|
||||||
|
import solutions.bitbadger.documents.common.FieldMatch
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class QueryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("whereByFields generates for a single field with logical operator")
|
||||||
|
fun whereByFieldSingleLogical() {
|
||||||
|
assertEquals("data->>'theField' > :test",
|
||||||
|
Query.whereByFields(FieldMatch.ANY, listOf(Field.greater("theField", 0).withParameterName(":test"))),
|
||||||
|
"WHERE clause not correct")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("whereByFields generates for a single field with existence operator")
|
||||||
|
fun whereByFieldSingleExistence() {
|
||||||
|
assertEquals("data->>'thatField' IS NULL",
|
||||||
|
Query.whereByFields(FieldMatch.ANY, listOf(Field.notExists("thatField"))), "WHERE clause not correct")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("whereByFields generates for a single field with between operator")
|
||||||
|
fun whereByFieldSingleBetween() {
|
||||||
|
assertEquals("data->>'aField' BETWEEN :rangemin AND :rangemax",
|
||||||
|
Query.whereByFields(FieldMatch.ALL, listOf(Field.between("aField", 50, 99).withParameterName(":range"))),
|
||||||
|
"WHERE clause not correct")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("whereByFields generates for all multiple fields with logical operator")
|
||||||
|
fun whereByFieldAllMultipleLogical() {
|
||||||
|
assertEquals("data->>'theFirst' = :field0 AND data->>'numberTwo' = :field1",
|
||||||
|
Query.whereByFields(FieldMatch.ALL, listOf(Field.equal("theFirst", "1"), Field.equal("numberTwo", "2"))),
|
||||||
|
"WHERE clause not correct")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("whereByFields generates for any multiple fields with existence operator")
|
||||||
|
fun whereByFieldAnyMultipleExistence() {
|
||||||
|
assertEquals("data->>'thatField' IS NULL OR data->>'thisField' >= :field0",
|
||||||
|
Query.whereByFields(FieldMatch.ANY,
|
||||||
|
listOf(Field.notExists("thatField"), Field.greaterOrEqual("thisField", 18))),
|
||||||
|
"WHERE clause not correct")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("whereByFields generates for an In comparison")
|
||||||
|
fun whereByFieldIn() {
|
||||||
|
assertEquals("data->>'this' IN (:field0_0, :field0_1, :field0_2)",
|
||||||
|
Query.whereByFields(FieldMatch.ALL, listOf(Field.any("this", listOf("a", "b", "c")))),
|
||||||
|
"WHERE clause not correct")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("whereByFields generates for an InArray comparison")
|
||||||
|
fun whereByFieldInArray() {
|
||||||
|
assertEquals("EXISTS (SELECT 1 FROM json_each(the_table.data, '$.this') WHERE value IN (:field0_0, :field0_1)",
|
||||||
|
Query.whereByFields(FieldMatch.ALL, listOf(Field.inArray("this", "the_table", listOf("a", "b")))),
|
||||||
|
"WHERE clause not correct")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("whereById generates correctly")
|
||||||
|
fun whereById() {
|
||||||
|
assertEquals("data->>'id' = :id", Query.whereById("abc"), "WHERE clause not correct")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("patch generates the correct query")
|
||||||
|
fun patch() {
|
||||||
|
assertEquals("UPDATE my_table SET data = json_patch(data, json(:data))", Query.patch("my_table"),
|
||||||
|
"Query not correct")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("byId generates the correct query")
|
||||||
|
fun byId() {
|
||||||
|
assertEquals("test WHERE data->>'id' = :id", Query.byId("test", "14"), "By-ID query not correct")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("byFields generates the correct query")
|
||||||
|
fun byFields() {
|
||||||
|
assertEquals("unit WHERE data->>'That' > :field0",
|
||||||
|
Query.byFields("unit", FieldMatch.ANY, listOf(Field.greater("That", 14))), "By-fields query not correct")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Definition.ensureTable generates the correct query")
|
||||||
|
fun ensureTable() {
|
||||||
|
assertEquals("CREATE TABLE IF NOT EXISTS tbl (data TEXT NOT NULL)", Query.Definition.ensureTable("tbl"),
|
||||||
|
"CREATE TABLE statement not correct")
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user