WIP on count impl; reworking comparisons

This commit is contained in:
2025-02-27 23:38:38 -05:00
parent 250e216ae8
commit a84c8289b1
20 changed files with 548 additions and 77 deletions

View File

@@ -1,27 +1,54 @@
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 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? */
val isNumeric: Boolean
get() {
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 val isNumeric: Boolean
get() = value.let { it is Byte || it is Short || it is Int || it is Long }
override fun toString() =
"$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>) =
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 ~~~
/**
@@ -94,6 +104,38 @@ inline fun <reified TDoc> Connection.insert(tableName: String, document: TDoc) =
fun Connection.countAll(tableName: String) =
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 ~~~
@@ -134,4 +176,4 @@ fun <TKey> Connection.byId(tableName: String, docId: TKey) =
* @param howMatched How the fields should be matched
*/
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) =
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
*
* @param tableName The table to be indexed (may include schema)
* @param indexName The name of the index to create
* @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>) =
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 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)
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 howMatched How the fields should be matched
*/
fun byField(tableName: String, fields: Collection<Field<*>>, howMatched: FieldMatch? = null) =
Configuration.dbConn().use { byField(tableName, fields, howMatched, it) }
fun byFields(tableName: String, fields: Collection<Field<*>>, howMatched: FieldMatch? = null) =
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() =
"Field ${parameterName ?: "<unnamed>"} $comparison${qualifier?.let { " (qualifier $it)"} ?: ""}"
@@ -106,7 +147,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison
*/
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
@@ -117,7 +158,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison
*/
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
@@ -128,7 +169,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison
*/
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
@@ -139,7 +180,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison
*/
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
@@ -150,7 +191,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison
*/
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
@@ -161,7 +202,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison
*/
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
@@ -173,7 +214,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison
*/
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`)
@@ -184,7 +225,7 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison
*/
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
@@ -196,16 +237,16 @@ class Field<T> private constructor(
* @return A `Field` with the given comparison
*/
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) =
Field(name, Comparison(Op.EXISTS, ""))
Field(name, SingleComparison(Op.EXISTS, ""))
fun notExists(name: String) =
Field(name, Comparison(Op.NOT_EXISTS, ""))
Field(name, SingleComparison(Op.NOT_EXISTS, ""))
fun named(name: String) =
Field(name, Comparison(Op.EQUAL, ""))
Field(name, SingleComparison(Op.EQUAL, ""))
fun nameToPath(name: String, dialect: Dialect, format: FieldFormat): String {
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)
* @return A collection of parameters for the query
*/
fun addFields(
fields: Collection<Field<*>>,
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
}
fun addFields(fields: Collection<Field<*>>, existing: MutableCollection<Parameter<*>> = mutableListOf()) =
fields.fold(existing) { acc, field -> field.appendParameter(acc) }
/**
* Replace the parameter names in the query with question marks
@@ -72,7 +58,7 @@ object Parameters {
*/
fun replaceNamesInQuery(query: String, parameters: Collection<Parameter<*>>) =
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
*

View File

@@ -1,9 +1,6 @@
package solutions.bitbadger.documents.query
import solutions.bitbadger.documents.Configuration
import solutions.bitbadger.documents.Dialect
import solutions.bitbadger.documents.Field
import solutions.bitbadger.documents.FieldFormat
import solutions.bitbadger.documents.*
/**
* Functions to create queries to define tables and indexes
@@ -76,4 +73,20 @@ object Definition {
*/
fun ensureKey(tableName: String, dialect: Dialect? = null) =
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})";
}
}