Initial Development #1

Merged
danieljsummers merged 88 commits from v1-rc into main 2025-04-16 01:29:20 +00:00
20 changed files with 548 additions and 77 deletions
Showing only changes of commit a84c8289b1 - Show all commits

View File

@ -1,7 +1,6 @@
package solutions.bitbadger.documents package solutions.bitbadger.documents
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.sql.Connection
/** The test table name to use for integration tests */ /** The test table name to use for integration tests */
const val TEST_TABLE = "test_table" const val TEST_TABLE = "test_table"

View File

@ -0,0 +1,69 @@
package solutions.bitbadger.documents.common
import solutions.bitbadger.documents.*
import kotlin.test.assertEquals
/**
* Integration tests for the `Count` object
*/
object Count {
fun all(db: ThrowawayDatabase) {
JsonDocument.load(db)
assertEquals(5L, db.conn.countAll(TEST_TABLE), "There should have been 5 documents in the table")
}
fun byFieldsNumeric(db: ThrowawayDatabase) {
JsonDocument.load(db)
assertEquals(
3L,
db.conn.countByFields(TEST_TABLE, listOf(Field.between("num_value", 10, 20))),
"There should have been 3 matching documents"
)
}
fun byFieldsAlpha(db: ThrowawayDatabase) {
JsonDocument.load(db)
assertEquals(
1L,
db.conn.countByFields(TEST_TABLE, listOf(Field.between("value", "aardvark", "apple"))),
"There should have been 1 matching document"
)
}
fun byContainsMatch(db: ThrowawayDatabase) {
JsonDocument.load(db)
assertEquals(
2L,
db.conn.countByContains(TEST_TABLE, mapOf("value" to "purple")),
"There should have been 2 matching documents"
)
}
fun byContainsNoMatch(db: ThrowawayDatabase) {
JsonDocument.load(db)
assertEquals(
0L,
db.conn.countByContains(TEST_TABLE, mapOf("value" to "magenta")),
"There should have been no matching documents"
)
}
fun byJsonPathMatch(db: ThrowawayDatabase) {
JsonDocument.load(db)
assertEquals(
2L,
db.conn.countByJsonPath(TEST_TABLE, "$.num_value ? (@ < 5)"),
"There should have been 2 matching documents"
)
}
fun byJsonPathNoMatch(db: ThrowawayDatabase) {
JsonDocument.load(db)
assertEquals(
0L,
db.conn.countByJsonPath(TEST_TABLE, "$.num_value ? (@ > 100)"),
"There should have been no matching documents"
)
}
}

View File

@ -1,9 +1,6 @@
package solutions.bitbadger.documents.common package solutions.bitbadger.documents.common
import solutions.bitbadger.documents.TEST_TABLE import solutions.bitbadger.documents.*
import solutions.bitbadger.documents.ThrowawayDatabase
import solutions.bitbadger.documents.ensureFieldIndex
import solutions.bitbadger.documents.ensureTable
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -25,4 +22,22 @@ object Definition {
db.conn.ensureFieldIndex(TEST_TABLE, "test", listOf("id", "category")) db.conn.ensureFieldIndex(TEST_TABLE, "test", listOf("id", "category"))
assertTrue(db.dbObjectExists("idx_${TEST_TABLE}_test"), "The test index should now exist") assertTrue(db.dbObjectExists("idx_${TEST_TABLE}_test"), "The test index should now exist")
} }
fun ensureDocumentIndexFull(db: ThrowawayDatabase) {
assertFalse(db.dbObjectExists("doc_table"), "The 'doc_table' table should not exist")
db.conn.ensureTable("doc_table")
assertTrue(db.dbObjectExists("doc_table"), "The 'doc_table' table should exist")
assertFalse(db.dbObjectExists("idx_doc_table_document"), "The document index should not exist")
db.conn.ensureDocumentIndex("doc_table", DocumentIndex.FULL)
assertTrue(db.dbObjectExists("idx_doc_table_document"), "The document index should exist")
}
fun ensureDocumentIndexOptimized(db: ThrowawayDatabase) {
assertFalse(db.dbObjectExists("doc_table"), "The 'doc_table' table should not exist")
db.conn.ensureTable("doc_table")
assertTrue(db.dbObjectExists("doc_table"), "The 'doc_table' table should exist")
assertFalse(db.dbObjectExists("idx_doc_table_document"), "The document index should not exist")
db.conn.ensureDocumentIndex("doc_table", DocumentIndex.OPTIMIZED)
assertTrue(db.dbObjectExists("idx_doc_table_document"), "The document index should exist")
}
} }

View File

@ -0,0 +1,47 @@
package solutions.bitbadger.documents.postgresql
import org.junit.jupiter.api.DisplayName
import solutions.bitbadger.documents.common.Count
import kotlin.test.Test
/**
* PostgreSQL integration tests for the `Count` object / `count*` connection extension functions
*/
@DisplayName("PostgreSQL - Count")
class CountIT {
@Test
@DisplayName("all counts all documents")
fun all() =
PgDB().use(Count::all)
@Test
@DisplayName("byFields counts documents by a numeric value")
fun byFieldsNumeric() =
PgDB().use(Count::byFieldsNumeric)
@Test
@DisplayName("byFields counts documents by a alphanumeric value")
fun byFieldsAlpha() =
PgDB().use(Count::byFieldsAlpha)
@Test
@DisplayName("byContains counts documents when matches are found")
fun byContainsMatch() =
PgDB().use(Count::byContainsMatch)
@Test
@DisplayName("byContains counts documents when no matches are found")
fun byContainsNoMatch() =
PgDB().use(Count::byContainsNoMatch)
@Test
@DisplayName("byJsonPath counts documents when matches are found")
fun byJsonPathMatch() =
PgDB().use(Count::byJsonPathMatch)
@Test
@DisplayName("byJsonPath counts documents when no matches are found")
fun byJsonPathNoMatch() =
PgDB().use(Count::byJsonPathNoMatch)
}

View File

@ -19,4 +19,14 @@ class DefinitionIT {
@DisplayName("ensureFieldIndex creates an index") @DisplayName("ensureFieldIndex creates an index")
fun ensureFieldIndex() = fun ensureFieldIndex() =
PgDB().use(Definition::ensureFieldIndex) PgDB().use(Definition::ensureFieldIndex)
@Test
@DisplayName("ensureDocumentIndex creates a full index")
fun ensureDocumentIndexFull() =
PgDB().use(Definition::ensureDocumentIndexFull)
@Test
@DisplayName("ensureDocumentIndex creates an optimized index")
fun ensureDocumentIndexOptimized() =
PgDB().use(Definition::ensureDocumentIndexOptimized)
} }

View File

@ -0,0 +1,41 @@
package solutions.bitbadger.documents.sqlite
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.assertThrows
import solutions.bitbadger.documents.DocumentException
import solutions.bitbadger.documents.common.Count
import kotlin.test.Test
/**
* SQLite integration tests for the `Count` object / `count*` connection extension functions
*/
@DisplayName("SQLite - Count")
class CountIT {
@Test
@DisplayName("all counts all documents")
fun all() =
SQLiteDB().use(Count::all)
@Test
@DisplayName("byFields counts documents by a numeric value")
fun byFieldsNumeric() =
SQLiteDB().use(Count::byFieldsNumeric)
@Test
@DisplayName("byFields counts documents by a alphanumeric value")
fun byFieldsAlpha() =
SQLiteDB().use(Count::byFieldsAlpha)
@Test
@DisplayName("byContains fails")
fun byContainsMatch() {
assertThrows<DocumentException> { SQLiteDB().use(Count::byContainsMatch) }
}
@Test
@DisplayName("byJsonPath fails")
fun byJsonPathMatch() {
assertThrows<DocumentException> { SQLiteDB().use(Count::byJsonPathMatch) }
}
}

View File

@ -1,12 +1,10 @@
package solutions.bitbadger.documents.sqlite package solutions.bitbadger.documents.sqlite
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.assertThrows
import solutions.bitbadger.documents.* import solutions.bitbadger.documents.*
import solutions.bitbadger.documents.common.Definition import solutions.bitbadger.documents.common.Definition
import java.sql.Connection
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/** /**
* SQLite integration tests for the `Definition` object / `ensure*` connection extension functions * SQLite integration tests for the `Definition` object / `ensure*` connection extension functions
@ -23,4 +21,16 @@ class DefinitionIT {
@DisplayName("ensureFieldIndex creates an index") @DisplayName("ensureFieldIndex creates an index")
fun ensureFieldIndex() = fun ensureFieldIndex() =
SQLiteDB().use(Definition::ensureFieldIndex) SQLiteDB().use(Definition::ensureFieldIndex)
@Test
@DisplayName("ensureDocumentIndex fails for full index")
fun ensureDocumentIndexFull() {
assertThrows<DocumentException> { SQLiteDB().use(Definition::ensureDocumentIndexFull) }
}
@Test
@DisplayName("ensureDocumentIndex fails for optimized index")
fun ensureDocumentIndexOptimized() {
assertThrows<DocumentException> { SQLiteDB().use(Definition::ensureDocumentIndexOptimized) }
}
} }

View File

@ -2,7 +2,6 @@ package solutions.bitbadger.documents.sqlite
import solutions.bitbadger.documents.* import solutions.bitbadger.documents.*
import java.io.File import java.io.File
import java.sql.Connection
/** /**
* A wrapper for a throwaway SQLite database * A wrapper for a throwaway SQLite database

View File

@ -1,27 +1,54 @@
package solutions.bitbadger.documents package solutions.bitbadger.documents
interface Comparison<T> {
val op: Op
val isNumeric: Boolean
val value: T
}
/** /**
* A comparison against a field in a JSON document * A single-value comparison against a field in a JSON document
* *
* @property op The operation for the field comparison * @property op The operation for the field comparison
* @property value The value against which the comparison will be made * @property value The value against which the comparison will be made
*/ */
class Comparison<T>(val op: Op, val value: T) { class SingleComparison<T>(override val op: Op, override val value: T) : Comparison<T> {
/** Is the value for this comparison a numeric value? */ /** Is the value for this comparison a numeric value? */
val isNumeric: Boolean override val isNumeric: Boolean
get() { get() = value.let { it is Byte || it is Short || it is Int || it is Long }
val toCheck = when (op) {
Op.IN -> {
val values = value as? Collection<*>
if (values.isNullOrEmpty()) "" else values.elementAt(0)
}
Op.BETWEEN -> (value as Pair<*, *>).first
else -> value
}
return toCheck is Byte || toCheck is Short || toCheck is Int || toCheck is Long
}
override fun toString() = override fun toString() =
"$op $value" "$op $value"
} }
/**
* A range comparison against a field in a JSON document
*/
class BetweenComparison<T>(override val op: Op = Op.BETWEEN, override val value: Pair<T, T>) : Comparison<Pair<T, T>> {
override val isNumeric: Boolean
get() = value.first.let { it is Byte || it is Short || it is Int || it is Long }
}
/**
* A check within a collection of values
*/
class InComparison<T>(override val op: Op = Op.IN, override val value: Collection<T>) : Comparison<Collection<T>> {
override val isNumeric: Boolean
get() = !value.isEmpty() && value.elementAt(0).let { it is Byte || it is Short || it is Int || it is Long }
}
/**
* A check within a collection of values
*/
class InArrayComparison<T>(override val op: Op = Op.IN_ARRAY, override val value: Pair<String, Collection<T>>) : Comparison<Pair<String, Collection<T>>> {
override val isNumeric: Boolean
get() = !value.second.isEmpty() && value.second.elementAt(0)
.let { it is Byte || it is Short || it is Int || it is Long }
}

View File

@ -72,6 +72,16 @@ fun Connection.ensureTable(tableName: String) =
fun Connection.ensureFieldIndex(tableName: String, indexName: String, fields: Collection<String>) = fun Connection.ensureFieldIndex(tableName: String, indexName: String, fields: Collection<String>) =
Definition.ensureFieldIndex(tableName, indexName, fields, this) Definition.ensureFieldIndex(tableName, indexName, fields, this)
/**
* Create a document index on a table (PostgreSQL only)
*
* @param tableName The table to be indexed (may include schema)
* @param indexType The type of index to ensure
* @throws DocumentException If called on a SQLite connection
*/
fun Connection.ensureDocumentIndex(tableName: String, indexType: DocumentIndex) =
Definition.ensureDocumentIndex(tableName, indexType, this)
// ~~~ DOCUMENT MANIPULATION QUERIES ~~~ // ~~~ DOCUMENT MANIPULATION QUERIES ~~~
/** /**
@ -94,6 +104,38 @@ inline fun <reified TDoc> Connection.insert(tableName: String, document: TDoc) =
fun Connection.countAll(tableName: String) = fun Connection.countAll(tableName: String) =
Count.all(tableName, this) Count.all(tableName, this)
/**
* Count documents using a field comparison
*
* @param tableName The name of the table in which documents should be counted
* @param fields The fields which should be compared
* @param howMatched How the fields should be matched
* @return A count of the matching documents in the table
*/
fun Connection.countByFields(tableName: String, fields: Collection<Field<*>>, howMatched: FieldMatch? = null) =
Count.byFields(tableName, fields, howMatched, this)
/**
* Count documents using a JSON containment query (PostgreSQL only)
*
* @param tableName The name of the table in which documents should be counted
* @param criteria The object for which JSON containment should be checked
* @return A count of the matching documents in the table
* @throws DocumentException If called on a SQLite connection
*/
inline fun <reified T> Connection.countByContains(tableName: String, criteria: T) =
Count.byContains(tableName, criteria, this)
/**
* Count documents using a JSON containment query (PostgreSQL only)
*
* @param tableName The name of the table in which documents should be counted
* @param path The JSON path comparison to match
* @return A count of the matching documents in the table
* @throws DocumentException If called on a SQLite connection
*/
fun Connection.countByJsonPath(tableName: String, path: String) =
Count.byJsonPath(tableName, path, this)
// ~~~ DOCUMENT RETRIEVAL QUERIES ~~~ // ~~~ DOCUMENT RETRIEVAL QUERIES ~~~
@ -134,4 +176,4 @@ fun <TKey> Connection.byId(tableName: String, docId: TKey) =
* @param howMatched How the fields should be matched * @param howMatched How the fields should be matched
*/ */
fun Connection.deleteByFields(tableName: String, fields: Collection<Field<*>>, howMatched: FieldMatch? = null) = fun Connection.deleteByFields(tableName: String, fields: Collection<Field<*>>, howMatched: FieldMatch? = null) =
Delete.byField(tableName, fields, howMatched, this) Delete.byFields(tableName, fields, howMatched, this)

View File

@ -26,4 +26,88 @@ object Count {
*/ */
fun all(tableName: String) = fun all(tableName: String) =
Configuration.dbConn().use { all(tableName, it) } Configuration.dbConn().use { all(tableName, it) }
/**
* Count documents using a field comparison
*
* @param tableName The name of the table in which documents should be counted
* @param fields The fields which should be compared
* @param howMatched How the fields should be matched
* @param conn The connection on which the deletion should be executed
* @return A count of the matching documents in the table
*/
fun byFields(
tableName: String,
fields: Collection<Field<*>>,
howMatched: FieldMatch? = null,
conn: Connection
): Long {
val named = Parameters.nameFields(fields)
return conn.customScalar(
Count.byFields(tableName, named, howMatched),
Parameters.addFields(named),
Results::toCount
)
}
/**
* Count documents using a field comparison
*
* @param tableName The name of the table in which documents should be counted
* @param fields The fields which should be compared
* @param howMatched How the fields should be matched
* @return A count of the matching documents in the table
*/
fun byFields(tableName: String, fields: Collection<Field<*>>, howMatched: FieldMatch? = null) =
Configuration.dbConn().use { byFields(tableName, fields, howMatched, it) }
/**
* Count documents using a JSON containment query (PostgreSQL only)
*
* @param tableName The name of the table in which documents should be counted
* @param criteria The object for which JSON containment should be checked
* @param conn The connection on which the deletion should be executed
* @return A count of the matching documents in the table
* @throws DocumentException If called on a SQLite connection
*/
inline fun <reified T> byContains(tableName: String, criteria: T, conn: Connection) =
conn.customScalar(Count.byContains(tableName), listOf(Parameters.json(":criteria", criteria)), Results::toCount)
/**
* Count documents using a JSON containment query (PostgreSQL only)
*
* @param tableName The name of the table in which documents should be counted
* @param criteria The object for which JSON containment should be checked
* @return A count of the matching documents in the table
* @throws DocumentException If called on a SQLite connection
*/
inline fun <reified T> byContains(tableName: String, criteria: T) =
Configuration.dbConn().use { byContains(tableName, criteria, it) }
/**
* Count documents using a JSON containment query (PostgreSQL only)
*
* @param tableName The name of the table in which documents should be counted
* @param path The JSON path comparison to match
* @param conn The connection on which the deletion should be executed
* @return A count of the matching documents in the table
* @throws DocumentException If called on a SQLite connection
*/
fun byJsonPath(tableName: String, path: String, conn: Connection) =
conn.customScalar(
Count.byJsonPath(tableName),
listOf(Parameter(":path", ParameterType.STRING, path)),
Results::toCount
)
/**
* Count documents using a JSON containment query (PostgreSQL only)
*
* @param tableName The name of the table in which documents should be counted
* @param path The JSON path comparison to match
* @return A count of the matching documents in the table
* @throws DocumentException If called on a SQLite connection
*/
fun byJsonPath(tableName: String, path: String) =
Configuration.dbConn().use { byJsonPath(tableName, path, it) }
} }

View File

@ -41,11 +41,32 @@ object Definition {
/** /**
* Create an index on field(s) within documents in the specified table if necessary * Create an index on field(s) within documents in the specified table if necessary
*
* @param tableName The table to be indexed (may include schema) * @param tableName The table to be indexed (may include schema)
* @param indexName The name of the index to create * @param indexName The name of the index to create
* @param fields One or more fields to be indexed< * @param fields One or more fields to be indexed<
* @param conn The connection on which the query should be executed
*/ */
fun ensureFieldIndex(tableName: String, indexName: String, fields: Collection<String>) = fun ensureFieldIndex(tableName: String, indexName: String, fields: Collection<String>) =
Configuration.dbConn().use { ensureFieldIndex(tableName, indexName, fields, it) } Configuration.dbConn().use { ensureFieldIndex(tableName, indexName, fields, it) }
/**
* Create a document index on a table (PostgreSQL only)
*
* @param tableName The table to be indexed (may include schema)
* @param indexType The type of index to ensure
* @param conn The connection on which the query should be executed
* @throws DocumentException If called on a SQLite connection
*/
fun ensureDocumentIndex(tableName: String, indexType: DocumentIndex, conn: Connection) =
conn.customNonQuery(Definition.ensureDocumentIndexOn(tableName, indexType))
/**
* Create a document index on a table (PostgreSQL only)
*
* @param tableName The table to be indexed (may include schema)
* @param indexType The type of index to ensure
* @throws DocumentException If called on a SQLite connection
*/
fun ensureDocumentIndex(tableName: String, indexType: DocumentIndex) =
Configuration.dbConn().use { ensureDocumentIndex(tableName, indexType, it) }
} }

View File

@ -38,7 +38,7 @@ object Delete {
* @param howMatched How the fields should be matched * @param howMatched How the fields should be matched
* @param conn The connection on which the deletion should be executed * @param conn The connection on which the deletion should be executed
*/ */
fun byField(tableName: String, fields: Collection<Field<*>>, howMatched: FieldMatch? = null, conn: Connection) { fun byFields(tableName: String, fields: Collection<Field<*>>, howMatched: FieldMatch? = null, conn: Connection) {
val named = Parameters.nameFields(fields) val named = Parameters.nameFields(fields)
conn.customNonQuery(Delete.byFields(tableName, named, howMatched), Parameters.addFields(named)) conn.customNonQuery(Delete.byFields(tableName, named, howMatched), Parameters.addFields(named))
} }
@ -50,6 +50,6 @@ object Delete {
* @param fields The fields which should be compared * @param fields The fields which should be compared
* @param howMatched How the fields should be matched * @param howMatched How the fields should be matched
*/ */
fun byField(tableName: String, fields: Collection<Field<*>>, howMatched: FieldMatch? = null) = fun byFields(tableName: String, fields: Collection<Field<*>>, howMatched: FieldMatch? = null) =
Configuration.dbConn().use { byField(tableName, fields, howMatched, it) } Configuration.dbConn().use { byFields(tableName, fields, howMatched, it) }
} }

View File

@ -0,0 +1,13 @@
package solutions.bitbadger.documents
/**
* The type of index to generate for the document
*/
enum class DocumentIndex(val sql: String) {
/** A GIN index with standard operations (all operators supported) */
FULL(""),
/** A GIN index with JSONPath operations (optimized for @>, @?, @@ operators) */
OPTIMIZED(" jsonb_path_ops")
}

View File

@ -92,6 +92,47 @@ class Field<T> private constructor(
} }
} }
/**
* Append the parameters required for this field
*
* @param existing The existing parameters
* @return The collection with the necessary parameters appended
*/
fun appendParameter(existing: MutableCollection<Parameter<*>>): MutableCollection<Parameter<*>> {
val typ = if (comparison.isNumeric) ParameterType.NUMBER else ParameterType.STRING
when (comparison) {
is BetweenComparison<*> -> {
existing.add(Parameter("${parameterName}min", typ, comparison.value.first))
existing.add(Parameter("${parameterName}max", typ, comparison.value.second))
}
is InComparison<*> -> {
comparison.value.forEachIndexed { index, item ->
existing.add(Parameter("${parameterName}_$index", typ, item))
}
}
is InArrayComparison<*> -> {
val mkString = Configuration.dialect("append parameters for InArray") == Dialect.POSTGRESQL
// TODO: I think this is actually Pair<String, Collection<*>>
comparison.value.second.forEachIndexed { index, item ->
if (mkString) {
existing.add(Parameter("${parameterName}_$index", ParameterType.STRING, "$item"))
} else {
existing.add(Parameter("${parameterName}_$index", typ, item))
}
}
}
else -> {
if (comparison.op != Op.EXISTS && comparison.op != Op.NOT_EXISTS) {
existing.add(Parameter(parameterName!!, typ, comparison.value))
}
}
}
return existing
}
override fun toString() = override fun toString() =
"Field ${parameterName ?: "<unnamed>"} $comparison${qualifier?.let { " (qualifier $it)"} ?: ""}" "Field ${parameterName ?: "<unnamed>"} $comparison${qualifier?.let { " (qualifier $it)"} ?: ""}"
@ -106,7 +147,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison * @return A `Field` with the given comparison
*/ */
fun <T> equal(name: String, value: T, paramName: String? = null) = fun <T> equal(name: String, value: T, paramName: String? = null) =
Field(name, Comparison(Op.EQUAL, value), paramName) Field(name, SingleComparison(Op.EQUAL, value), paramName)
/** /**
* Create a field greater-than comparison * Create a field greater-than comparison
@ -117,7 +158,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison * @return A `Field` with the given comparison
*/ */
fun <T> greater(name: String, value: T, paramName: String? = null) = fun <T> greater(name: String, value: T, paramName: String? = null) =
Field(name, Comparison(Op.GREATER, value), paramName) Field(name, SingleComparison(Op.GREATER, value), paramName)
/** /**
* Create a field greater-than-or-equal-to comparison * Create a field greater-than-or-equal-to comparison
@ -128,7 +169,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison * @return A `Field` with the given comparison
*/ */
fun <T> greaterOrEqual(name: String, value: T, paramName: String? = null) = fun <T> greaterOrEqual(name: String, value: T, paramName: String? = null) =
Field(name, Comparison(Op.GREATER_OR_EQUAL, value), paramName) Field(name, SingleComparison(Op.GREATER_OR_EQUAL, value), paramName)
/** /**
* Create a field less-than comparison * Create a field less-than comparison
@ -139,7 +180,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison * @return A `Field` with the given comparison
*/ */
fun <T> less(name: String, value: T, paramName: String? = null) = fun <T> less(name: String, value: T, paramName: String? = null) =
Field(name, Comparison(Op.LESS, value), paramName) Field(name, SingleComparison(Op.LESS, value), paramName)
/** /**
* Create a field less-than-or-equal-to comparison * Create a field less-than-or-equal-to comparison
@ -150,7 +191,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison * @return A `Field` with the given comparison
*/ */
fun <T> lessOrEqual(name: String, value: T, paramName: String? = null) = fun <T> lessOrEqual(name: String, value: T, paramName: String? = null) =
Field(name, Comparison(Op.LESS_OR_EQUAL, value), paramName) Field(name, SingleComparison(Op.LESS_OR_EQUAL, value), paramName)
/** /**
* Create a field inequality comparison * Create a field inequality comparison
@ -161,7 +202,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison * @return A `Field` with the given comparison
*/ */
fun <T> notEqual(name: String, value: T, paramName: String? = null) = fun <T> notEqual(name: String, value: T, paramName: String? = null) =
Field(name, Comparison(Op.NOT_EQUAL, value), paramName) Field(name, SingleComparison(Op.NOT_EQUAL, value), paramName)
/** /**
* Create a field range comparison * Create a field range comparison
@ -173,7 +214,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison * @return A `Field` with the given comparison
*/ */
fun <T> between(name: String, minValue: T, maxValue: T, paramName: String? = null) = fun <T> between(name: String, minValue: T, maxValue: T, paramName: String? = null) =
Field(name, Comparison(Op.BETWEEN, Pair(minValue, maxValue)), paramName) Field(name, BetweenComparison(value = Pair(minValue, maxValue)), paramName)
/** /**
* Create a field where any values match (SQL `IN`) * Create a field where any values match (SQL `IN`)
@ -184,7 +225,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison * @return A `Field` with the given comparison
*/ */
fun <T> any(name: String, values: Collection<T>, paramName: String? = null) = fun <T> any(name: String, values: Collection<T>, paramName: String? = null) =
Field(name, Comparison(Op.IN, values), paramName) Field(name, InComparison(value = values), paramName)
/** /**
* Create a field where values should exist in a document's array * Create a field where values should exist in a document's array
@ -196,16 +237,16 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison * @return A `Field` with the given comparison
*/ */
fun <T> inArray(name: String, tableName: String, values: Collection<T>, paramName: String? = null) = fun <T> inArray(name: String, tableName: String, values: Collection<T>, paramName: String? = null) =
Field(name, Comparison(Op.IN_ARRAY, Pair(tableName, values)), paramName) Field(name, InArrayComparison(value = Pair(tableName, values)), paramName)
fun exists(name: String) = fun exists(name: String) =
Field(name, Comparison(Op.EXISTS, "")) Field(name, SingleComparison(Op.EXISTS, ""))
fun notExists(name: String) = fun notExists(name: String) =
Field(name, Comparison(Op.NOT_EXISTS, "")) Field(name, SingleComparison(Op.NOT_EXISTS, ""))
fun named(name: String) = fun named(name: String) =
Field(name, Comparison(Op.EQUAL, "")) Field(name, SingleComparison(Op.EQUAL, ""))
fun nameToPath(name: String, dialect: Dialect, format: FieldFormat): String { fun nameToPath(name: String, dialect: Dialect, format: FieldFormat): String {
val path = StringBuilder("data") val path = StringBuilder("data")

View File

@ -46,22 +46,8 @@ object Parameters {
* @param existing Any existing parameters for the query (optional, defaults to empty collection) * @param existing Any existing parameters for the query (optional, defaults to empty collection)
* @return A collection of parameters for the query * @return A collection of parameters for the query
*/ */
fun addFields( fun addFields(fields: Collection<Field<*>>, existing: MutableCollection<Parameter<*>> = mutableListOf()) =
fields: Collection<Field<*>>, fields.fold(existing) { acc, field -> field.appendParameter(acc) }
existing: MutableCollection<Parameter<*>> = mutableListOf()
): MutableCollection<Parameter<*>> {
existing.addAll(
fields
.filter { it.comparison.op != Op.EXISTS && it.comparison.op != Op.NOT_EXISTS }
.map {
Parameter(
it.parameterName!!,
if (it.comparison.isNumeric) ParameterType.NUMBER else ParameterType.STRING,
it.comparison.value
)
})
return existing
}
/** /**
* Replace the parameter names in the query with question marks * Replace the parameter names in the query with question marks
@ -72,7 +58,7 @@ object Parameters {
*/ */
fun replaceNamesInQuery(query: String, parameters: Collection<Parameter<*>>) = fun replaceNamesInQuery(query: String, parameters: Collection<Parameter<*>>) =
parameters.sortedByDescending { it.name.length }.fold(query) { acc, param -> acc.replace(param.name, "?") } parameters.sortedByDescending { it.name.length }.fold(query) { acc, param -> acc.replace(param.name, "?") }
.also(::println)
/** /**
* Apply the given parameters to the given query, returning a prepared statement * Apply the given parameters to the given query, returning a prepared statement
* *

View File

@ -1,9 +1,6 @@
package solutions.bitbadger.documents.query package solutions.bitbadger.documents.query
import solutions.bitbadger.documents.Configuration import solutions.bitbadger.documents.*
import solutions.bitbadger.documents.Dialect
import solutions.bitbadger.documents.Field
import solutions.bitbadger.documents.FieldFormat
/** /**
* Functions to create queries to define tables and indexes * Functions to create queries to define tables and indexes
@ -76,4 +73,20 @@ object Definition {
*/ */
fun ensureKey(tableName: String, dialect: Dialect? = null) = fun ensureKey(tableName: String, dialect: Dialect? = null) =
ensureIndexOn(tableName, "key", listOf(Configuration.idField), dialect).replace("INDEX", "UNIQUE INDEX") ensureIndexOn(tableName, "key", listOf(Configuration.idField), dialect).replace("INDEX", "UNIQUE INDEX")
/**
* Create a document-wide index on a table (PostgreSQL only)
*
* @param tableName The name of the table on which the document index should be created
* @param indexType The type of index to be created
* @return The SQL statement to create an index on JSON documents in the specified table
* @throws DocumentException If the database mode is not PostgreSQL
*/
fun ensureDocumentIndexOn(tableName: String, indexType: DocumentIndex): String {
if (Configuration.dialect("create document index query") != Dialect.POSTGRESQL) {
throw DocumentException("'Document indexes are only supported on PostgreSQL")
}
val (_, tbl) = splitSchemaAndTable(tableName)
return "CREATE INDEX IF NOT EXISTS idx_${tbl}_document ON $tableName USING GIN (data${indexType.sql})";
}
} }

View File

@ -14,82 +14,82 @@ class ComparisonTest {
@Test @Test
@DisplayName("isNumeric is false for empty list of values") @DisplayName("isNumeric is false for empty list of values")
fun isNumericFalseForEmptyList() = fun isNumericFalseForEmptyList() =
assertFalse(Comparison(Op.IN, listOf<Int>()).isNumeric, "An IN with empty list should not be numeric") assertFalse(InComparison(Op.IN, listOf<Int>()).isNumeric, "An IN with empty list should not be numeric")
@Test @Test
@DisplayName("isNumeric is false for IN with strings") @DisplayName("isNumeric is false for IN with strings")
fun isNumericFalseForStringsAndIn() = fun isNumericFalseForStringsAndIn() =
assertFalse(Comparison(Op.IN, listOf("a", "b", "c")).isNumeric, "An IN with strings should not be numeric") assertFalse(InComparison(Op.IN, listOf("a", "b", "c")).isNumeric, "An IN with strings should not be numeric")
@Test @Test
@DisplayName("isNumeric is true for IN with bytes") @DisplayName("isNumeric is true for IN with bytes")
fun isNumericTrueForByteAndIn() = fun isNumericTrueForByteAndIn() =
assertTrue(Comparison(Op.IN, listOf<Byte>(4, 8)).isNumeric, "An IN with bytes should be numeric") assertTrue(InComparison(Op.IN, listOf<Byte>(4, 8)).isNumeric, "An IN with bytes should be numeric")
@Test @Test
@DisplayName("isNumeric is true for IN with shorts") @DisplayName("isNumeric is true for IN with shorts")
fun isNumericTrueForShortAndIn() = fun isNumericTrueForShortAndIn() =
assertTrue(Comparison(Op.IN, listOf<Short>(18, 22)).isNumeric, "An IN with shorts should be numeric") assertTrue(InComparison(Op.IN, listOf<Short>(18, 22)).isNumeric, "An IN with shorts should be numeric")
@Test @Test
@DisplayName("isNumeric is true for IN with ints") @DisplayName("isNumeric is true for IN with ints")
fun isNumericTrueForIntAndIn() = fun isNumericTrueForIntAndIn() =
assertTrue(Comparison(Op.IN, listOf(7, 8, 9)).isNumeric, "An IN with ints should be numeric") assertTrue(InComparison(Op.IN, listOf(7, 8, 9)).isNumeric, "An IN with ints should be numeric")
@Test @Test
@DisplayName("isNumeric is true for IN with longs") @DisplayName("isNumeric is true for IN with longs")
fun isNumericTrueForLongAndIn() = fun isNumericTrueForLongAndIn() =
assertTrue(Comparison(Op.IN, listOf(3L)).isNumeric, "An IN with longs should be numeric") assertTrue(InComparison(Op.IN, listOf(3L)).isNumeric, "An IN with longs should be numeric")
@Test @Test
@DisplayName("isNumeric is false for BETWEEN with strings") @DisplayName("isNumeric is false for BETWEEN with strings")
fun isNumericFalseForStringsAndBetween() = fun isNumericFalseForStringsAndBetween() =
assertFalse(Comparison(Op.BETWEEN, Pair("eh", "zed")).isNumeric, assertFalse(BetweenComparison(Op.BETWEEN, Pair("eh", "zed")).isNumeric,
"A BETWEEN with strings should not be numeric") "A BETWEEN with strings should not be numeric")
@Test @Test
@DisplayName("isNumeric is true for BETWEEN with bytes") @DisplayName("isNumeric is true for BETWEEN with bytes")
fun isNumericTrueForByteAndBetween() = fun isNumericTrueForByteAndBetween() =
assertTrue(Comparison(Op.BETWEEN, Pair<Byte, Byte>(7, 11)).isNumeric, "A BETWEEN with bytes should be numeric") assertTrue(BetweenComparison(Op.BETWEEN, Pair<Byte, Byte>(7, 11)).isNumeric, "A BETWEEN with bytes should be numeric")
@Test @Test
@DisplayName("isNumeric is true for BETWEEN with shorts") @DisplayName("isNumeric is true for BETWEEN with shorts")
fun isNumericTrueForShortAndBetween() = fun isNumericTrueForShortAndBetween() =
assertTrue(Comparison(Op.BETWEEN, Pair<Short, Short>(0, 9)).isNumeric, assertTrue(BetweenComparison(Op.BETWEEN, Pair<Short, Short>(0, 9)).isNumeric,
"A BETWEEN with shorts should be numeric") "A BETWEEN with shorts should be numeric")
@Test @Test
@DisplayName("isNumeric is true for BETWEEN with ints") @DisplayName("isNumeric is true for BETWEEN with ints")
fun isNumericTrueForIntAndBetween() = fun isNumericTrueForIntAndBetween() =
assertTrue(Comparison(Op.BETWEEN, Pair(15, 44)).isNumeric, "A BETWEEN with ints should be numeric") assertTrue(BetweenComparison(Op.BETWEEN, Pair(15, 44)).isNumeric, "A BETWEEN with ints should be numeric")
@Test @Test
@DisplayName("isNumeric is true for BETWEEN with longs") @DisplayName("isNumeric is true for BETWEEN with longs")
fun isNumericTrueForLongAndBetween() = fun isNumericTrueForLongAndBetween() =
assertTrue(Comparison(Op.BETWEEN, Pair(9L, 12L)).isNumeric, "A BETWEEN with longs should be numeric") assertTrue(BetweenComparison(Op.BETWEEN, Pair(9L, 12L)).isNumeric, "A BETWEEN with longs should be numeric")
@Test @Test
@DisplayName("isNumeric is false for string value") @DisplayName("isNumeric is false for string value")
fun isNumericFalseForString() = fun isNumericFalseForString() =
assertFalse(Comparison(Op.EQUAL, "80").isNumeric, "A string should not be numeric") assertFalse(SingleComparison(Op.EQUAL, "80").isNumeric, "A string should not be numeric")
@Test @Test
@DisplayName("isNumeric is true for byte value") @DisplayName("isNumeric is true for byte value")
fun isNumericTrueForByte() = fun isNumericTrueForByte() =
assertTrue(Comparison(Op.EQUAL, 47.toByte()).isNumeric, "A byte should be numeric") assertTrue(SingleComparison(Op.EQUAL, 47.toByte()).isNumeric, "A byte should be numeric")
@Test @Test
@DisplayName("isNumeric is true for short value") @DisplayName("isNumeric is true for short value")
fun isNumericTrueForShort() = fun isNumericTrueForShort() =
assertTrue(Comparison(Op.EQUAL, 2.toShort()).isNumeric, "A short should be numeric") assertTrue(SingleComparison(Op.EQUAL, 2.toShort()).isNumeric, "A short should be numeric")
@Test @Test
@DisplayName("isNumeric is true for int value") @DisplayName("isNumeric is true for int value")
fun isNumericTrueForInt() = fun isNumericTrueForInt() =
assertTrue(Comparison(Op.EQUAL, 555).isNumeric, "An int should be numeric") assertTrue(SingleComparison(Op.EQUAL, 555).isNumeric, "An int should be numeric")
@Test @Test
@DisplayName("isNumeric is true for long value") @DisplayName("isNumeric is true for long value")
fun isNumericTrueForLong() = fun isNumericTrueForLong() =
assertTrue(Comparison(Op.EQUAL, 82L).isNumeric, "A long should be numeric") assertTrue(SingleComparison(Op.EQUAL, 82L).isNumeric, "A long should be numeric")
} }

View File

@ -0,0 +1,24 @@
package solutions.bitbadger.documents
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
/**
* Unit tests for the `DocumentIndex` enum
*/
@DisplayName("Op")
class DocumentIndexTest {
@Test
@DisplayName("FULL uses proper SQL")
fun fullSQL() {
assertEquals("", DocumentIndex.FULL.sql, "The SQL for Full is incorrect")
}
@Test
@DisplayName("OPTIMIZED uses proper SQL")
fun optimizedSQL() {
assertEquals(" jsonb_path_ops", DocumentIndex.OPTIMIZED.sql, "The SQL for Optimized is incorrect")
}
}

View File

@ -7,6 +7,7 @@ import org.junit.jupiter.api.assertThrows
import solutions.bitbadger.documents.Configuration import solutions.bitbadger.documents.Configuration
import solutions.bitbadger.documents.Dialect import solutions.bitbadger.documents.Dialect
import solutions.bitbadger.documents.DocumentException import solutions.bitbadger.documents.DocumentException
import solutions.bitbadger.documents.DocumentIndex
import kotlin.test.assertEquals import kotlin.test.assertEquals
/** /**
@ -101,4 +102,33 @@ class DefinitionTest {
Definition.ensureIndexOn(tbl, "nest", listOf("a.b.c"), Dialect.SQLITE), Definition.ensureIndexOn(tbl, "nest", listOf("a.b.c"), Dialect.SQLITE),
"CREATE INDEX for nested SQLite field incorrect" "CREATE INDEX for nested SQLite field incorrect"
) )
@Test
@DisplayName("ensureDocumentIndexOn generates Full for PostgreSQL")
fun ensureDocumentIndexOnFullPostgres() {
Configuration.dialectValue = Dialect.POSTGRESQL
assertEquals(
"CREATE INDEX IF NOT EXISTS idx_${tbl}_document ON $tbl USING GIN (data)",
Definition.ensureDocumentIndexOn(tbl, DocumentIndex.FULL),
"CREATE INDEX for full document index incorrect"
)
}
@Test
@DisplayName("ensureDocumentIndexOn generates Optimized for PostgreSQL")
fun ensureDocumentIndexOnOptimizedPostgres() {
Configuration.dialectValue = Dialect.POSTGRESQL
assertEquals(
"CREATE INDEX IF NOT EXISTS idx_${tbl}_document ON $tbl USING GIN (data jsonb_path_ops)",
Definition.ensureDocumentIndexOn(tbl, DocumentIndex.OPTIMIZED),
"CREATE INDEX for optimized document index incorrect"
)
}
@Test
@DisplayName("ensureDocumentIndexOn fails for SQLite")
fun ensureDocumentIndexOnFailsSQLite() {
Configuration.dialectValue = Dialect.SQLITE
assertThrows<DocumentException> { Definition.ensureDocumentIndexOn(tbl, DocumentIndex.FULL) }
}
} }