diff --git a/src/main/kotlin/Configuration.kt b/src/main/kotlin/Configuration.kt index 069f9a4..fa473c4 100644 --- a/src/main/kotlin/Configuration.kt +++ b/src/main/kotlin/Configuration.kt @@ -27,7 +27,7 @@ object Configuration { var idStringLength = 16 /** The derived dialect value from the connection string */ - private var dialectValue: Dialect? = null + internal var dialectValue: Dialect? = null /** The connection string for the JDBC connection */ var connectionString: String? = null diff --git a/src/main/kotlin/Query.kt b/src/main/kotlin/Query.kt deleted file mode 100644 index d4da1f7..0000000 --- a/src/main/kotlin/Query.kt +++ /dev/null @@ -1,504 +0,0 @@ -package solutions.bitbadger.documents - -object Query { - - /** - * Combine a query (`SELECT`, `UPDATE`, etc.) and a `WHERE` clause - * - * @param statement The first part of the statement - * @param where The `WHERE` clause for the statement - * @return The two parts of the query combined with `WHERE` - */ - fun statementWhere(statement: String, where: String) = - "$statement WHERE $where" - - /** - * Functions to create `WHERE` clause fragments - */ - object Where { - - /** - * Create a `WHERE` clause fragment to query by one or more fields - * - * @param fields The fields to be queried - * @param howMatched How the fields should be matched (optional, defaults to `ALL`) - * @return A `WHERE` clause fragment to match the given fields - */ - fun byFields(fields: Collection>, 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 byId(parameterName: String = ":id", docId: TKey? = null) = - byFields(listOf(Field.equal(Configuration.idField, docId ?: "", 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 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 fields The field conditions to be matched - * @param howMatched Whether to match any or all of the field conditions (optional; default ALL) - * @return A query addressing documents by field matching conditions - */ - fun byFields(statement: String, fields: Collection>, howMatched: FieldMatch? = null) = - 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) = - "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) = - tableName.split('.').let { if (it.size == 1) Pair("", tableName) else Pair(it[0], it[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, 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) = - 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) = - insert(tableName, AutoId.DISABLED) + - " ON CONFLICT ((data->>'${Configuration.idField}')) DO UPDATE SET data = EXCLUDED.data" - - /** - * Functions to count documents - */ - object Count { - - /** - * Query to count all documents in a table - * - * @param tableName The table in which to count documents (may include schema) - * @return A query to count documents - */ - fun all(tableName: String) = - "SELECT COUNT(*) AS it FROM $tableName" - - /** - * Query to count documents matching the given fields - * - * @param tableName The table in which to count documents (may include schema) - * @param fields The field comparisons for the count - * @param howMatched How fields should be compared (optional, defaults to ALL) - * @return A query to count documents matching the given fields - */ - fun byFields(tableName: String, fields: Collection>, howMatched: FieldMatch? = null) = - statementWhere(all(tableName), Where.byFields(fields, howMatched)) - - /** - * Query to count documents via JSON containment (PostgreSQL only) - * - * @param tableName The table in which to count documents (may include schema) - * @return A query to count documents via JSON containment - */ - fun byContains(tableName: String) = - statementWhere(all(tableName), Where.jsonContains()) - - /** - * Query to count documents via a JSON path match (PostgreSQL only) - * - * @param tableName The table in which to count documents (may include schema) - * @return A query to count documents via a JSON path match - */ - fun byJsonPath(tableName: String) = - statementWhere(all(tableName), Where.jsonPathMatches()) - } - - /** - * Functions to check for document existence - */ - object Exists { - - /** - * 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 - */ - private fun exists(tableName: String, where: String) = - "SELECT EXISTS (SELECT 1 FROM $tableName WHERE $where) AS it" - - /** - * Query to check for document existence by ID - * - * @param tableName The table in which existence should be checked (may include schema) - * @param docId The ID of the document (optional, used for type checking) - * @return A query to determine document existence by ID - */ - fun byId(tableName: String, docId: TKey? = null) = - exists(tableName, Where.byId(docId = docId)) - - /** - * Query to check for document existence matching the given fields - * - * @param tableName The table in which existence should be checked (may include schema) - * @param fields The field comparisons for the existence check - * @param howMatched How fields should be compared (optional, defaults to ALL) - * @return A query to determine document existence for the given fields - */ - fun byFields(tableName: String, fields: Collection>, howMatched: FieldMatch? = null) = - exists(tableName, Where.byFields(fields, howMatched)) - - /** - * Query to check for document existence via JSON containment (PostgreSQL only) - * - * @param tableName The table in which existence should be checked (may include schema) - * @return A query to determine document existence via JSON containment - */ - fun byContains(tableName: String) = - exists(tableName, Where.jsonContains()) - - /** - * Query to check for document existence via a JSON path match (PostgreSQL only) - * - * @param tableName The table in which existence should be checked (may include schema) - * @return A query to determine document existence via a JSON path match - */ - fun byJsonPath(tableName: String) = - exists(tableName, Where.jsonPathMatches()) - } - - /** - * Functions to retrieve documents - */ - object Find { - - /** - * Query to retrieve all documents from a table - * - * @param tableName The table from which documents should be retrieved (may include schema) - * @return A query to retrieve documents - */ - fun all(tableName: String) = - "SELECT data FROM $tableName" - - /** - * Query to retrieve a document by its ID - * - * @param tableName The table from which documents should be retrieved (may include schema) - * @param docId The ID of the document (optional, used for type checking) - * @return A query to retrieve a document by its ID - */ - fun byId(tableName: String, docId: TKey?) = - statementWhere(all(tableName), Where.byId(docId = docId)) - - /** - * Query to retrieve documents matching the given fields - * - * @param tableName The table from which documents should be retrieved (may include schema) - * @param fields The field comparisons for matching documents to retrieve - * @param howMatched How fields should be compared (optional, defaults to ALL) - * @return A query to retrieve documents matching the given fields - */ - fun byFields(tableName: String, fields: Collection>, howMatched: FieldMatch?) = - statementWhere(all(tableName), Where.byFields(fields, howMatched)) - - /** - * Query to retrieve documents via JSON containment (PostgreSQL only) - * - * @param tableName The table from which documents should be retrieved (may include schema) - * @return A query to retrieve documents via JSON containment - */ - fun byContains(tableName: String) = - statementWhere(all(tableName), Where.jsonContains()) - - /** - * Query to retrieve documents via a JSON path match (PostgreSQL only) - * - * @param tableName The table from which documents should be retrieved (may include schema) - * @return A query to retrieve documents via a JSON path match - */ - fun byJsonPath(tableName: String) = - statementWhere(all(tableName), Where.jsonPathMatches()) - } - - /** - * 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) = - "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 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>, 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 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 byJsonPath(tableName: String) = - patch(tableName, Where.jsonPathMatches()) - } - - /** - * Functions to delete documents - */ - object Delete { - - /** - * Query to delete documents from a table - * - * @param tableName The table in which documents should be deleted (may include schema) - * @param where The WHERE clause for the delete statement - * @return A query to delete documents - */ - private fun delete(tableName: String, where: String) = - statementWhere("DELETE FROM $tableName", where) - - /** - * Query to delete a document by its ID - * - * @param tableName The table from which documents should be deleted (may include schema) - * @param docId The ID of the document (optional, used for type checking) - * @return A query to delete a document by its ID - */ - fun byId(tableName: String, docId: TKey?) = - delete(tableName, Where.byId(docId = docId)) - - /** - * Query to delete documents matching the given fields - * - * @param tableName The table from which documents should be deleted (may include schema) - * @param fields The field comparisons for documents to be deleted - * @param howMatched How fields should be compared (optional, defaults to ALL) - * @return A query to delete documents matching for the given fields - */ - fun byFields(tableName: String, fields: Collection>, howMatched: FieldMatch?) = - delete(tableName, Where.byFields(fields, howMatched)) - - /** - * Query to delete documents via JSON containment (PostgreSQL only) - * - * @param tableName The table from which documents should be deleted (may include schema) - * @return A query to delete documents via JSON containment - */ - fun byContains(tableName: String) = - delete(tableName, Where.jsonContains()) - - /** - * Query to delete documents via a JSON path match (PostgreSQL only) - * - * @param tableName The table from which documents should be deleted (may include schema) - * @return A query to delete documents via a JSON path match - */ - fun byJsonPath(tableName: String) = - delete(tableName, Where.jsonPathMatches()) - } - - /** - * 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>, 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, 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" - } -} diff --git a/src/main/kotlin/query/Count.kt b/src/main/kotlin/query/Count.kt new file mode 100644 index 0000000..e3db117 --- /dev/null +++ b/src/main/kotlin/query/Count.kt @@ -0,0 +1,49 @@ +package solutions.bitbadger.documents.query + +import solutions.bitbadger.documents.Field +import solutions.bitbadger.documents.FieldMatch +import solutions.bitbadger.documents.query.byFields as byFieldsBase; + +/** + * Functions to count documents + */ +object Count { + + /** + * Query to count all documents in a table + * + * @param tableName The table in which to count documents (may include schema) + * @return A query to count documents + */ + fun all(tableName: String) = + "SELECT COUNT(*) AS it FROM $tableName" + + /** + * Query to count documents matching the given fields + * + * @param tableName The table in which to count documents (may include schema) + * @param fields The field comparisons for the count + * @param howMatched How fields should be compared (optional, defaults to ALL) + * @return A query to count documents matching the given fields + */ + fun byFields(tableName: String, fields: Collection>, howMatched: FieldMatch? = null) = + byFieldsBase(all(tableName), fields, howMatched) + + /** + * Query to count documents via JSON containment (PostgreSQL only) + * + * @param tableName The table in which to count documents (may include schema) + * @return A query to count documents via JSON containment + */ + fun byContains(tableName: String) = + statementWhere(all(tableName), Where.jsonContains()) + + /** + * Query to count documents via a JSON path match (PostgreSQL only) + * + * @param tableName The table in which to count documents (may include schema) + * @return A query to count documents via a JSON path match + */ + fun byJsonPath(tableName: String) = + statementWhere(all(tableName), Where.jsonPathMatches()) +} diff --git a/src/main/kotlin/query/Definition.kt b/src/main/kotlin/query/Definition.kt new file mode 100644 index 0000000..b2425e4 --- /dev/null +++ b/src/main/kotlin/query/Definition.kt @@ -0,0 +1,72 @@ +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 + +/** + * 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) = + "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) = + tableName.split('.').let { if (it.size == 1) Pair("", tableName) else Pair(it[0], it[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, 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) = + ensureIndexOn(tableName, "key", listOf(Configuration.idField), dialect).replace("INDEX", "UNIQUE INDEX") +} diff --git a/src/main/kotlin/query/Delete.kt b/src/main/kotlin/query/Delete.kt new file mode 100644 index 0000000..53882f5 --- /dev/null +++ b/src/main/kotlin/query/Delete.kt @@ -0,0 +1,61 @@ +package solutions.bitbadger.documents.query + +import solutions.bitbadger.documents.Field +import solutions.bitbadger.documents.FieldMatch +import solutions.bitbadger.documents.query.byFields as byFieldsBase +import solutions.bitbadger.documents.query.byId as byIdBase + +/** + * Functions to delete documents + */ +object Delete { + + /** + * Query to delete documents from a table + * + * @param tableName The table in which documents should be deleted (may include schema) + * @param where The WHERE clause for the delete statement + * @return A query to delete documents + */ + private fun delete(tableName: String) = + "DELETE FROM $tableName" + + /** + * Query to delete a document by its ID + * + * @param tableName The table from which documents should be deleted (may include schema) + * @param docId The ID of the document (optional, used for type checking) + * @return A query to delete a document by its ID + */ + fun byId(tableName: String, docId: TKey? = null) = + byIdBase(delete(tableName), docId) + + /** + * Query to delete documents matching the given fields + * + * @param tableName The table from which documents should be deleted (may include schema) + * @param fields The field comparisons for documents to be deleted + * @param howMatched How fields should be compared (optional, defaults to ALL) + * @return A query to delete documents matching for the given fields + */ + fun byFields(tableName: String, fields: Collection>, howMatched: FieldMatch? = null) = + byFieldsBase(delete(tableName), fields, howMatched) + + /** + * Query to delete documents via JSON containment (PostgreSQL only) + * + * @param tableName The table from which documents should be deleted (may include schema) + * @return A query to delete documents via JSON containment + */ + fun byContains(tableName: String) = + statementWhere(delete(tableName), Where.jsonContains()) + + /** + * Query to delete documents via a JSON path match (PostgreSQL only) + * + * @param tableName The table from which documents should be deleted (may include schema) + * @return A query to delete documents via a JSON path match + */ + fun byJsonPath(tableName: String) = + statementWhere(delete(tableName), Where.jsonPathMatches()) +} diff --git a/src/main/kotlin/query/Document.kt b/src/main/kotlin/query/Document.kt new file mode 100644 index 0000000..c60c986 --- /dev/null +++ b/src/main/kotlin/query/Document.kt @@ -0,0 +1,58 @@ +package solutions.bitbadger.documents.query + +import solutions.bitbadger.documents.AutoId +import solutions.bitbadger.documents.Configuration +import solutions.bitbadger.documents.Dialect + +/** + * Functions for document-level operations + */ +object Document { + + /** + * 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) = + insert(tableName, AutoId.DISABLED) + + " ON CONFLICT ((data->>'${Configuration.idField}')) DO UPDATE SET data = EXCLUDED.data" + + /** + * 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) = + "UPDATE $tableName SET data = :data" +} diff --git a/src/main/kotlin/query/Exists.kt b/src/main/kotlin/query/Exists.kt new file mode 100644 index 0000000..54689d0 --- /dev/null +++ b/src/main/kotlin/query/Exists.kt @@ -0,0 +1,59 @@ +package solutions.bitbadger.documents.query + +import solutions.bitbadger.documents.Field +import solutions.bitbadger.documents.FieldMatch + +/** + * Functions to check for document existence + */ +object Exists { + + /** + * 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 + */ + private fun exists(tableName: String, where: String) = + "SELECT EXISTS (SELECT 1 FROM $tableName WHERE $where) AS it" + + /** + * Query to check for document existence by ID + * + * @param tableName The table in which existence should be checked (may include schema) + * @param docId The ID of the document (optional, used for type checking) + * @return A query to determine document existence by ID + */ + fun byId(tableName: String, docId: TKey? = null) = + exists(tableName, Where.byId(docId = docId)) + + /** + * Query to check for document existence matching the given fields + * + * @param tableName The table in which existence should be checked (may include schema) + * @param fields The field comparisons for the existence check + * @param howMatched How fields should be compared (optional, defaults to ALL) + * @return A query to determine document existence for the given fields + */ + fun byFields(tableName: String, fields: Collection>, howMatched: FieldMatch? = null) = + exists(tableName, Where.byFields(fields, howMatched)) + + /** + * Query to check for document existence via JSON containment (PostgreSQL only) + * + * @param tableName The table in which existence should be checked (may include schema) + * @return A query to determine document existence via JSON containment + */ + fun byContains(tableName: String) = + exists(tableName, Where.jsonContains()) + + /** + * Query to check for document existence via a JSON path match (PostgreSQL only) + * + * @param tableName The table in which existence should be checked (may include schema) + * @return A query to determine document existence via a JSON path match + */ + fun byJsonPath(tableName: String) = + exists(tableName, Where.jsonPathMatches()) +} diff --git a/src/main/kotlin/query/Find.kt b/src/main/kotlin/query/Find.kt new file mode 100644 index 0000000..6d57232 --- /dev/null +++ b/src/main/kotlin/query/Find.kt @@ -0,0 +1,60 @@ +package solutions.bitbadger.documents.query + +import solutions.bitbadger.documents.Field +import solutions.bitbadger.documents.FieldMatch +import solutions.bitbadger.documents.query.byId as byIdBase +import solutions.bitbadger.documents.query.byFields as byFieldsBase + +/** + * Functions to retrieve documents + */ +object Find { + + /** + * Query to retrieve all documents from a table + * + * @param tableName The table from which documents should be retrieved (may include schema) + * @return A query to retrieve documents + */ + fun all(tableName: String) = + "SELECT data FROM $tableName" + + /** + * Query to retrieve a document by its ID + * + * @param tableName The table from which documents should be retrieved (may include schema) + * @param docId The ID of the document (optional, used for type checking) + * @return A query to retrieve a document by its ID + */ + fun byId(tableName: String, docId: TKey? = null) = + byIdBase(all(tableName), docId) + + /** + * Query to retrieve documents matching the given fields + * + * @param tableName The table from which documents should be retrieved (may include schema) + * @param fields The field comparisons for matching documents to retrieve + * @param howMatched How fields should be compared (optional, defaults to ALL) + * @return A query to retrieve documents matching the given fields + */ + fun byFields(tableName: String, fields: Collection>, howMatched: FieldMatch? = null) = + byFieldsBase(all(tableName), fields, howMatched) + + /** + * Query to retrieve documents via JSON containment (PostgreSQL only) + * + * @param tableName The table from which documents should be retrieved (may include schema) + * @return A query to retrieve documents via JSON containment + */ + fun byContains(tableName: String) = + statementWhere(all(tableName), Where.jsonContains()) + + /** + * Query to retrieve documents via a JSON path match (PostgreSQL only) + * + * @param tableName The table from which documents should be retrieved (may include schema) + * @return A query to retrieve documents via a JSON path match + */ + fun byJsonPath(tableName: String) = + statementWhere(all(tableName), Where.jsonPathMatches()) +} diff --git a/src/main/kotlin/query/Patch.kt b/src/main/kotlin/query/Patch.kt new file mode 100644 index 0000000..60d2dbf --- /dev/null +++ b/src/main/kotlin/query/Patch.kt @@ -0,0 +1,65 @@ +package solutions.bitbadger.documents.query + +import solutions.bitbadger.documents.Configuration +import solutions.bitbadger.documents.Dialect +import solutions.bitbadger.documents.Field +import solutions.bitbadger.documents.FieldMatch +import solutions.bitbadger.documents.query.byFields as byFieldsBase +import solutions.bitbadger.documents.query.byId as byIdBase + +/** + * 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 + * @return A query to patch documents + */ + private fun patch(tableName: String) = + when (Configuration.dialect("create patch query")) { + Dialect.POSTGRESQL -> "data || :data" + Dialect.SQLITE -> "json_patch(data, json(:data))" + }.let { "UPDATE $tableName SET data = $it" } + + /** + * 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 byId(tableName: String, docId: TKey? = null) = + byIdBase(patch(tableName), 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>, howMatched: FieldMatch? = null) = + byFieldsBase(patch(tableName), 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 byContains(tableName: String) = + statementWhere(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 byJsonPath(tableName: String) = + statementWhere(patch(tableName), Where.jsonPathMatches()) +} diff --git a/src/main/kotlin/query/Query.kt b/src/main/kotlin/query/Query.kt new file mode 100644 index 0000000..20e6096 --- /dev/null +++ b/src/main/kotlin/query/Query.kt @@ -0,0 +1,75 @@ +package solutions.bitbadger.documents.query + +import solutions.bitbadger.documents.Dialect +import solutions.bitbadger.documents.Field +import solutions.bitbadger.documents.FieldMatch + +// ~~~ TOP-LEVEL FUNCTIONS FOR THE QUERY PACKAGE ~~~ + +/** + * 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" + +/** + * 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 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 fields The field conditions to be matched + * @param howMatched Whether to match any or all of the field conditions (optional; default ALL) + * @return A query addressing documents by field matching conditions + */ +fun byFields(statement: String, fields: Collection>, howMatched: FieldMatch? = null) = + statementWhere(statement, Where.byFields(fields, howMatched)) + +/** + * 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>, 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, 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" +} diff --git a/src/main/kotlin/query/Where.kt b/src/main/kotlin/query/Where.kt new file mode 100644 index 0000000..d33f40b --- /dev/null +++ b/src/main/kotlin/query/Where.kt @@ -0,0 +1,54 @@ +package solutions.bitbadger.documents.query + +import solutions.bitbadger.documents.* + +/** + * 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>, 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 byId(parameterName: String = ":id", docId: TKey? = null) = + byFields(listOf(Field.equal(Configuration.idField, docId ?: "", 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") + } +} diff --git a/src/test/kotlin/AutoIdTest.kt b/src/test/kotlin/AutoIdTest.kt index f99c0e0..e67df54 100644 --- a/src/test/kotlin/AutoIdTest.kt +++ b/src/test/kotlin/AutoIdTest.kt @@ -8,6 +8,10 @@ import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertTrue +/** + * Unit tests for the `AutoId` enum + */ +@DisplayName("AutoId") class AutoIdTest { @Test diff --git a/src/test/kotlin/ComparisonTest.kt b/src/test/kotlin/ComparisonTest.kt index 7c015e0..daa0545 100644 --- a/src/test/kotlin/ComparisonTest.kt +++ b/src/test/kotlin/ComparisonTest.kt @@ -5,6 +5,10 @@ import org.junit.jupiter.api.Test import kotlin.test.assertFalse import kotlin.test.assertTrue +/** + * Unit tests for the `Comparison` class + */ +@DisplayName("Comparison") class ComparisonTest { @Test diff --git a/src/test/kotlin/ConfigurationTest.kt b/src/test/kotlin/ConfigurationTest.kt index cc8f06d..acb8981 100644 --- a/src/test/kotlin/ConfigurationTest.kt +++ b/src/test/kotlin/ConfigurationTest.kt @@ -7,6 +7,10 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +/** + * Unit tests for the `Configuration` object + */ +@DisplayName("Configuration") class ConfigurationTest { @Test diff --git a/src/test/kotlin/DialectTest.kt b/src/test/kotlin/DialectTest.kt index a7e4015..29c6c51 100644 --- a/src/test/kotlin/DialectTest.kt +++ b/src/test/kotlin/DialectTest.kt @@ -6,6 +6,10 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue +/** + * Unit tests for the `Dialect` enum + */ +@DisplayName("Dialect") class DialectTest { @Test diff --git a/src/test/kotlin/FieldMatchTest.kt b/src/test/kotlin/FieldMatchTest.kt index cbb01bb..00ba366 100644 --- a/src/test/kotlin/FieldMatchTest.kt +++ b/src/test/kotlin/FieldMatchTest.kt @@ -4,6 +4,10 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import kotlin.test.assertEquals +/** + * Unit tests for the `FieldMatch` enum + */ +@DisplayName("FieldMatch") class FieldMatchTest { @Test diff --git a/src/test/kotlin/FieldTest.kt b/src/test/kotlin/FieldTest.kt index 5aeff2a..56e54f9 100644 --- a/src/test/kotlin/FieldTest.kt +++ b/src/test/kotlin/FieldTest.kt @@ -8,6 +8,10 @@ import kotlin.test.assertEquals import kotlin.test.assertNotSame import kotlin.test.assertNull +/** + * Unit tests for the `Field` class + */ +@DisplayName("Field") class FieldTest { /** diff --git a/src/test/kotlin/OpTest.kt b/src/test/kotlin/OpTest.kt index b69b386..f34099b 100644 --- a/src/test/kotlin/OpTest.kt +++ b/src/test/kotlin/OpTest.kt @@ -4,6 +4,10 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import kotlin.test.assertEquals +/** + * Unit tests for the `Op` enum + */ +@DisplayName("Op") class OpTest { @Test diff --git a/src/test/kotlin/ParameterNameTest.kt b/src/test/kotlin/ParameterNameTest.kt index e63162e..44ed76b 100644 --- a/src/test/kotlin/ParameterNameTest.kt +++ b/src/test/kotlin/ParameterNameTest.kt @@ -4,6 +4,10 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import kotlin.test.assertEquals +/** + * Unit tests for the `ParameterName` class + */ +@DisplayName("ParameterName") class ParameterNameTest { @Test diff --git a/src/test/kotlin/ParameterTest.kt b/src/test/kotlin/ParameterTest.kt index d9d0b2f..9f08515 100644 --- a/src/test/kotlin/ParameterTest.kt +++ b/src/test/kotlin/ParameterTest.kt @@ -6,6 +6,10 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +/** + * Unit tests for the `Parameter` class + */ +@DisplayName("Parameter") class ParameterTest { @Test diff --git a/src/test/kotlin/ParametersTest.kt b/src/test/kotlin/ParametersTest.kt index 1901df7..293fac2 100644 --- a/src/test/kotlin/ParametersTest.kt +++ b/src/test/kotlin/ParametersTest.kt @@ -6,6 +6,10 @@ import kotlin.test.assertEquals import kotlin.test.assertNotSame import kotlin.test.assertSame +/** + * Unit tests for the `Parameters` object + */ +@DisplayName("Parameters") class ParametersTest { @Test diff --git a/src/test/kotlin/QueryTest.kt b/src/test/kotlin/QueryTest.kt deleted file mode 100644 index ec8c19e..0000000 --- a/src/test/kotlin/QueryTest.kt +++ /dev/null @@ -1,685 +0,0 @@ -package solutions.bitbadger.documents - -import org.junit.jupiter.api.AfterEach -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.assertTrue - -class QueryTest { - - /** Test table name */ - private val tbl = "test_table" - - /** Dummy connection string for PostgreSQL */ - private val pg = ":postgresql:" - - /** Dummy connection string for SQLite */ - private val lite = ":sqlite:" - - /** - * Clear the connection string (resets Dialect) - */ - @AfterEach - fun cleanUp() { - Configuration.connectionString = null - } - - @Test - @DisplayName("statementWhere generates correctly") - fun statementWhere() = - assertEquals("x WHERE y", Query.statementWhere("x", "y"), "Statements not combined correctly") - - // ~~~ Where ~~~ - - @Test - @DisplayName("Where.byFields is blank when given no fields") - fun whereByFieldsBlankIfEmpty() = - assertEquals("", Query.Where.byFields(listOf())) - - @Test - @DisplayName("Where.byFields generates one numeric field (PostgreSQL)") - fun whereByFieldsOneFieldPostgres() { - Configuration.connectionString = pg - assertEquals("(data->>'it')::numeric = :that", Query.Where.byFields(listOf(Field.equal("it", 9, ":that")))) - } - - @Test - @DisplayName("Where.byFields generates one alphanumeric field (PostgreSQL)") - fun whereByFieldsOneAlphaFieldPostgres() { - Configuration.connectionString = pg - assertEquals("data->>'it' = :that", Query.Where.byFields(listOf(Field.equal("it", "", ":that")))) - } - - @Test - @DisplayName("Where.byFields generates one field (SQLite)") - fun whereByFieldsOneFieldSQLite() { - Configuration.connectionString = lite - assertEquals("data->>'it' = :that", Query.Where.byFields(listOf(Field.equal("it", "", ":that")))) - } - - @Test - @DisplayName("Where.byFields generates multiple fields w/ default match (PostgreSQL)") - fun whereByFieldsMultipleDefaultPostgres() { - Configuration.connectionString = pg - assertEquals( - "data->>'1' = :one AND (data->>'2')::numeric = :two AND data->>'3' = :three", - Query.Where.byFields( - listOf(Field.equal("1", "", ":one"), Field.equal("2", 0L, ":two"), Field.equal("3", "", ":three")) - ) - ) - } - - @Test - @DisplayName("Where.byFields generates multiple fields w/ default match (SQLite)") - fun whereByFieldsMultipleDefaultSQLite() { - Configuration.connectionString = lite - assertEquals( - "data->>'1' = :one AND data->>'2' = :two AND data->>'3' = :three", - Query.Where.byFields( - listOf(Field.equal("1", "", ":one"), Field.equal("2", 0L, ":two"), Field.equal("3", "", ":three")) - ) - ) - } - - @Test - @DisplayName("Where.byFields generates multiple fields w/ ANY match (PostgreSQL)") - fun whereByFieldsMultipleAnyPostgres() { - Configuration.connectionString = pg - assertEquals( - "data->>'1' = :one OR (data->>'2')::numeric = :two OR data->>'3' = :three", - Query.Where.byFields( - listOf(Field.equal("1", "", ":one"), Field.equal("2", 0L, ":two"), Field.equal("3", "", ":three")), - FieldMatch.ANY - ) - ) - } - - @Test - @DisplayName("Where.byFields generates multiple fields w/ ANY match (SQLite)") - fun whereByFieldsMultipleAnySQLite() { - Configuration.connectionString = lite - assertEquals( - "data->>'1' = :one OR data->>'2' = :two OR data->>'3' = :three", - Query.Where.byFields( - listOf(Field.equal("1", "", ":one"), Field.equal("2", 0L, ":two"), Field.equal("3", "", ":three")), - FieldMatch.ANY - ) - ) - } - - @Test - @DisplayName("Where.byId generates defaults for alphanumeric key (PostgreSQL)") - fun whereByIdDefaultAlphaPostgres() { - Configuration.connectionString = pg - assertEquals("data->>'id' = :id", Query.Where.byId(docId = "")) - } - - @Test - @DisplayName("Where.byId generates defaults for numeric key (PostgreSQL)") - fun whereByIdDefaultNumericPostgres() { - Configuration.connectionString = pg - assertEquals("(data->>'id')::numeric = :id", Query.Where.byId(docId = 5)) - } - - @Test - @DisplayName("Where.byId generates defaults (SQLite)") - fun whereByIdDefaultSQLite() { - Configuration.connectionString = lite - assertEquals("data->>'id' = :id", Query.Where.byId(docId = "")) - } - - @Test - @DisplayName("Where.byId generates named ID (PostgreSQL)") - fun whereByIdDefaultNamedPostgres() { - Configuration.connectionString = pg - assertEquals("data->>'id' = :key", Query.Where.byId(":key")) - } - - @Test - @DisplayName("Where.byId generates named ID (SQLite)") - fun whereByIdDefaultNamedSQLite() { - Configuration.connectionString = lite - assertEquals("data->>'id' = :key", Query.Where.byId(":key")) - } - - @Test - @DisplayName("Where.jsonContains generates defaults (PostgreSQL)") - fun whereJsonContainsDefaultPostgres() { - Configuration.connectionString = pg - assertEquals("data @> :criteria", Query.Where.jsonContains()) - } - - @Test - @DisplayName("Where.jsonContains generates named parameter (PostgreSQL)") - fun whereJsonContainsNamedPostgres() { - Configuration.connectionString = pg - assertEquals("data @> :it", Query.Where.jsonContains(":it")) - } - - @Test - @DisplayName("Where.jsonContains fails (SQLite)") - fun whereJsonContainsFailsSQLite() { - Configuration.connectionString = lite - assertThrows { Query.Where.jsonContains() } - } - - @Test - @DisplayName("Where.jsonPathMatch generates defaults (PostgreSQL)") - fun whereJsonPathMatchDefaultPostgres() { - Configuration.connectionString = pg - assertEquals("jsonb_path_exists(data, :path::jsonpath)", Query.Where.jsonPathMatches()) - } - - @Test - @DisplayName("Where.jsonPathMatch generates named parameter (PostgreSQL)") - fun whereJsonPathMatchNamedPostgres() { - Configuration.connectionString = pg - assertEquals("jsonb_path_exists(data, :jp::jsonpath)", Query.Where.jsonPathMatches(":jp")) - } - - @Test - @DisplayName("Where.jsonPathMatch fails (SQLite)") - fun whereJsonPathFailsSQLite() { - Configuration.connectionString = lite - assertThrows { Query.Where.jsonPathMatches() } - } - - // ~~~ root functions ~~~ - - @Test - @DisplayName("byId generates a numeric ID query (PostgreSQL)") - fun byIdNumericPostgres() { - Configuration.connectionString = pg - assertEquals("test WHERE (data->>'id')::numeric = :id", Query.byId("test", 9)) - } - - @Test - @DisplayName("byId generates an alphanumeric ID query (PostgreSQL)") - fun byIdAlphaPostgres() { - Configuration.connectionString = pg - assertEquals("unit WHERE data->>'id' = :id", Query.byId("unit", "18")) - } - - @Test - @DisplayName("byId generates ID query (SQLite)") - fun byIdSQLite() { - Configuration.connectionString = lite - assertEquals("yo WHERE data->>'id' = :id", Query.byId("yo", 27)) - } - - @Test - @DisplayName("byFields generates default field query (PostgreSQL)") - fun byFieldsMultipleDefaultPostgres() { - Configuration.connectionString = pg - assertEquals( - "this WHERE data->>'a' = :the_a AND (data->>'b')::numeric = :b_value", - Query.byFields("this", listOf(Field.equal("a", "", ":the_a"), Field.equal("b", 0, ":b_value"))) - ) - } - - @Test - @DisplayName("byFields generates default field query (SQLite)") - fun byFieldsMultipleDefaultSQLite() { - Configuration.connectionString = lite - assertEquals( - "this WHERE data->>'a' = :the_a AND data->>'b' = :b_value", - Query.byFields("this", listOf(Field.equal("a", "", ":the_a"), Field.equal("b", 0, ":b_value"))) - ) - } - - @Test - @DisplayName("byFields generates ANY field query (PostgreSQL)") - fun byFieldsMultipleAnyPostgres() { - Configuration.connectionString = pg - assertEquals( - "that WHERE data->>'a' = :the_a OR (data->>'b')::numeric = :b_value", - Query.byFields( - "that", listOf(Field.equal("a", "", ":the_a"), Field.equal("b", 0, ":b_value")), - FieldMatch.ANY - ) - ) - } - - @Test - @DisplayName("byFields generates ANY field query (SQLite)") - fun byFieldsMultipleAnySQLite() { - Configuration.connectionString = lite - assertEquals( - "that WHERE data->>'a' = :the_a OR data->>'b' = :b_value", - Query.byFields( - "that", listOf(Field.equal("a", "", ":the_a"), Field.equal("b", 0, ":b_value")), - FieldMatch.ANY - ) - ) - } - - // ~~~ Definition ~~~ - - @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.ensureTable generates correctly (PostgreSQL)") - fun ensureTablePostgres() { - Configuration.connectionString = pg - assertEquals("CREATE TABLE IF NOT EXISTS $tbl (data JSONB NOT NULL)", Query.Definition.ensureTable(tbl)) - } - - @Test - @DisplayName("Definition.ensureTable generates correctly (SQLite)") - fun ensureTableSQLite() { - Configuration.connectionString = lite - assertEquals("CREATE TABLE IF NOT EXISTS $tbl (data TEXT NOT NULL)", Query.Definition.ensureTable(tbl)) - } - - @Test - @DisplayName("Definition.ensureTable fails when no dialect is set") - fun ensureTableFailsUnknown() { - assertThrows { Query.Definition.ensureTable(tbl) } - } - - @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" - ) - - // ~~~ root functions ~~~ - - @Test - @DisplayName("insert generates no auto ID (PostgreSQL)") - fun insertNoAutoPostgres() { - Configuration.connectionString = pg - assertEquals("INSERT INTO $tbl VALUES (:data)", Query.insert(tbl)) - } - - @Test - @DisplayName("insert generates no auto ID (SQLite)") - fun insertNoAutoSQLite() { - Configuration.connectionString = lite - assertEquals("INSERT INTO $tbl VALUES (:data)", Query.insert(tbl)) - } - - @Test - @DisplayName("insert generates auto number (PostgreSQL)") - fun insertAutoNumberPostgres() { - Configuration.connectionString = pg - assertEquals( - "INSERT INTO $tbl VALUES (:data::jsonb || ('{\"id\":' " + - "|| (SELECT COALESCE(MAX((data->>'id')::numeric), 0) + 1 FROM $tbl) || '}')::jsonb)", - Query.insert(tbl, AutoId.NUMBER) - ) - } - - @Test - @DisplayName("insert generates auto number (SQLite)") - fun insertAutoNumberSQLite() { - Configuration.connectionString = lite - assertEquals( - "INSERT INTO $tbl VALUES (json_set(:data, '$.id', " + - "(SELECT coalesce(max(data->>'id'), 0) + 1 FROM $tbl)))", - Query.insert(tbl, AutoId.NUMBER) - ) - } - - @Test - @DisplayName("insert generates auto UUID (PostgreSQL)") - fun insertAutoUUIDPostgres() { - Configuration.connectionString = pg - val query = Query.insert(tbl, AutoId.UUID) - assertTrue( - query.startsWith("INSERT INTO $tbl VALUES (:data::jsonb || '{\"id\":\""), - "Query start not correct (actual: $query)" - ) - assertTrue(query.endsWith("\"}')"), "Query end not correct") - } - - @Test - @DisplayName("insert generates auto UUID (SQLite)") - fun insertAutoUUIDSQLite() { - Configuration.connectionString = lite - val query = Query.insert(tbl, AutoId.UUID) - assertTrue( - query.startsWith("INSERT INTO $tbl VALUES (json_set(:data, '$.id', '"), - "Query start not correct (actual: $query)" - ) - assertTrue(query.endsWith("'))"), "Query end not correct") - } - - @Test - @DisplayName("insert generates auto random string (PostgreSQL)") - fun insertAutoRandomPostgres() { - try { - Configuration.connectionString = pg - Configuration.idStringLength = 8 - val query = Query.insert(tbl, AutoId.RANDOM_STRING) - assertTrue( - query.startsWith("INSERT INTO $tbl VALUES (:data::jsonb || '{\"id\":\""), - "Query start not correct (actual: $query)" - ) - assertTrue(query.endsWith("\"}')"), "Query end not correct") - assertEquals( - 8, - query.replace("INSERT INTO $tbl VALUES (:data::jsonb || '{\"id\":\"", "").replace("\"}')", "").length, - "Random string length incorrect" - ) - } finally { - Configuration.idStringLength = 16 - } - } - - @Test - @DisplayName("insert generates auto random string (SQLite)") - fun insertAutoRandomSQLite() { - Configuration.connectionString = lite - val query = Query.insert(tbl, AutoId.RANDOM_STRING) - assertTrue( - query.startsWith("INSERT INTO $tbl VALUES (json_set(:data, '$.id', '"), - "Query start not correct (actual: $query)" - ) - assertTrue(query.endsWith("'))"), "Query end not correct") - assertEquals( - Configuration.idStringLength, - query.replace("INSERT INTO $tbl VALUES (json_set(:data, '$.id', '", "").replace("'))", "").length, - "Random string length incorrect" - ) - } - - @Test - @DisplayName("insert fails when no dialect is set") - fun insertFailsUnknown() { - assertThrows { Query.insert(tbl) } - } - - @Test - @DisplayName("save generates correctly") - fun save() { - Configuration.connectionString = pg - 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" - ) - } - - @Test - @DisplayName("Count.all generates correctly") - fun countAll() = - assertEquals("SELECT COUNT(*) AS it FROM $tbl", Query.Count.all(tbl), "Count query not constructed correctly") - - @Test - @DisplayName("Count.byFields generates correctly (PostgreSQL)") - fun countByFieldsPostgres() { - Configuration.connectionString = pg - assertEquals( - "SELECT COUNT(*) AS it FROM $tbl WHERE data->>'test' = :field0", - Query.Count.byFields(tbl, listOf(Field.equal("test", "", ":field0"))), - "Count query not constructed correctly" - ) - } - - @Test - @DisplayName("Count.byFields generates correctly (PostgreSQL)") - fun countByFieldsSQLite() { - Configuration.connectionString = lite - assertEquals( - "SELECT COUNT(*) AS it FROM $tbl WHERE data->>'test' = :field0", - Query.Count.byFields(tbl, listOf(Field.equal("test", "", ":field0"))), - "Count query not constructed correctly" - ) - } - - @Test - @DisplayName("Count.byContains generates correctly (PostgreSQL)") - fun countByContainsPostgres() { - Configuration.connectionString = pg - assertEquals( - "SELECT COUNT(*) AS it FROM $tbl WHERE data @> :criteria", Query.Count.byContains(tbl), - "Count query not constructed correctly" - ) - } - - @Test - @DisplayName("Count.byContains fails (SQLite)") - fun countByContainsSQLite() { - Configuration.connectionString = lite - assertThrows { Query.Count.byContains(tbl) } - } - - @Test - @DisplayName("Count.byJsonPath generates correctly (PostgreSQL)") - fun countByJsonPathPostgres() { - Configuration.connectionString = pg - assertEquals( - "SELECT COUNT(*) AS it FROM $tbl WHERE jsonb_path_exists(data, :path::jsonpath)", - Query.Count.byJsonPath(tbl), "Count query not constructed correctly" - ) - } - - @Test - @DisplayName("Count.byJsonPath fails (SQLite)") - fun countByJsonPathSQLite() { - Configuration.connectionString = lite - assertThrows { Query.Count.byJsonPath(tbl) } - } - - @Test - @DisplayName("Exists.byId generates correctly (PostgreSQL)") - fun existsByIdPostgres() { - Configuration.connectionString = pg - assertEquals( - "SELECT EXISTS (SELECT 1 FROM $tbl WHERE data->>'id' = :id) AS it", - Query.Exists.byId(tbl), "Exists query not constructed correctly" - ) - } - - @Test - @DisplayName("Exists.byId generates correctly (SQLite)") - fun existsByIdSQLite() { - Configuration.connectionString = lite - assertEquals( - "SELECT EXISTS (SELECT 1 FROM $tbl WHERE data->>'id' = :id) AS it", - Query.Exists.byId(tbl), "Exists query not constructed correctly" - ) - } - - @Test - @DisplayName("Exists.byFields generates correctly (PostgreSQL)") - fun existsByFieldsPostgres() { - Configuration.connectionString = pg - assertEquals( - "SELECT EXISTS (SELECT 1 FROM $tbl WHERE (data->>'it')::numeric = :test) AS it", - Query.Exists.byFields(tbl, listOf(Field.equal("it", 7, ":test"))), - "Exists query not constructed correctly" - ) - } - - @Test - @DisplayName("Exists.byFields generates correctly (SQLite)") - fun existsByFieldsSQLite() { - Configuration.connectionString = lite - assertEquals( - "SELECT EXISTS (SELECT 1 FROM $tbl WHERE data->>'it' = :test) AS it", - Query.Exists.byFields(tbl, listOf(Field.equal("it", 7, ":test"))), - "Exists query not constructed correctly" - ) - } - - @Test - @DisplayName("Exists.byContains generates correctly (PostgreSQL)") - fun existsByContainsPostgres() { - Configuration.connectionString = pg - assertEquals( - "SELECT EXISTS (SELECT 1 FROM $tbl WHERE data @> :criteria) AS it", Query.Exists.byContains(tbl), - "Exists query not constructed correctly" - ) - } - - @Test - @DisplayName("Exists.byContains fails (SQLite)") - fun existsByContainsSQLite() { - Configuration.connectionString = lite - assertThrows { Query.Exists.byContains(tbl) } - } - - @Test - @DisplayName("Exists.byJsonPath generates correctly (PostgreSQL)") - fun existsByJsonPathPostgres() { - Configuration.connectionString = pg - assertEquals( - "SELECT EXISTS (SELECT 1 FROM $tbl WHERE jsonb_path_exists(data, :path::jsonpath)) AS it", - Query.Exists.byJsonPath(tbl), "Exists query not constructed correctly" - ) - } - - @Test - @DisplayName("Exists.byJsonPath fails (SQLite)") - fun existsByJsonPathSQLite() { - Configuration.connectionString = lite - assertThrows { Query.Exists.byJsonPath(tbl) } - } - - @Test - @DisplayName("Find.all generates correctly") - fun findAll() = - assertEquals("SELECT data FROM $tbl", Query.Find.all(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" - ) -} diff --git a/src/test/kotlin/query/CountTest.kt b/src/test/kotlin/query/CountTest.kt new file mode 100644 index 0000000..3caab47 --- /dev/null +++ b/src/test/kotlin/query/CountTest.kt @@ -0,0 +1,87 @@ +package solutions.bitbadger.documents.query + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import solutions.bitbadger.documents.* +import kotlin.test.assertEquals + +/** + * Unit tests for the `Count` object + */ +@DisplayName("Count (Query)") +class CountTest { + + /** Test table name */ + private val tbl = "test_table" + + /** + * Clear the connection string (resets Dialect) + */ + @AfterEach + fun cleanUp() { + Configuration.dialectValue = null + } + + @Test + @DisplayName("all generates correctly") + fun all() = + assertEquals("SELECT COUNT(*) AS it FROM $tbl", Count.all(tbl), "Count query not constructed correctly") + + @Test + @DisplayName("byFields generates correctly (PostgreSQL)") + fun byFieldsPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "SELECT COUNT(*) AS it FROM $tbl WHERE data->>'test' = :field0", + Count.byFields(tbl, listOf(Field.equal("test", "", ":field0"))), + "Count query not constructed correctly" + ) + } + + @Test + @DisplayName("byFields generates correctly (PostgreSQL)") + fun byFieldsSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals( + "SELECT COUNT(*) AS it FROM $tbl WHERE data->>'test' = :field0", + Count.byFields(tbl, listOf(Field.equal("test", "", ":field0"))), + "Count query not constructed correctly" + ) + } + + @Test + @DisplayName("byContains generates correctly (PostgreSQL)") + fun byContainsPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "SELECT COUNT(*) AS it FROM $tbl WHERE data @> :criteria", Count.byContains(tbl), + "Count query not constructed correctly" + ) + } + + @Test + @DisplayName("byContains fails (SQLite)") + fun byContainsSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertThrows { Count.byContains(tbl) } + } + + @Test + @DisplayName("byJsonPath generates correctly (PostgreSQL)") + fun byJsonPathPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "SELECT COUNT(*) AS it FROM $tbl WHERE jsonb_path_exists(data, :path::jsonpath)", + Count.byJsonPath(tbl), "Count query not constructed correctly" + ) + } + + @Test + @DisplayName("byJsonPath fails (SQLite)") + fun byJsonPathSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertThrows { Count.byJsonPath(tbl) } + } +} diff --git a/src/test/kotlin/query/DefinitionTest.kt b/src/test/kotlin/query/DefinitionTest.kt new file mode 100644 index 0000000..018fb1d --- /dev/null +++ b/src/test/kotlin/query/DefinitionTest.kt @@ -0,0 +1,104 @@ +package solutions.bitbadger.documents.query + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import solutions.bitbadger.documents.Configuration +import solutions.bitbadger.documents.Dialect +import solutions.bitbadger.documents.DocumentException +import kotlin.test.assertEquals + +/** + * Unit tests for the `Definition` object + */ +@DisplayName("Definition (Query)") +class DefinitionTest { + + /** Test table name */ + private val tbl = "test_table" + + /** + * Clear the connection string (resets Dialect) + */ + @AfterEach + fun cleanUp() { + Configuration.dialectValue = null + } + + @Test + @DisplayName("ensureTableFor generates correctly") + fun ensureTableFor() = + assertEquals( + "CREATE TABLE IF NOT EXISTS my.table (data JSONB NOT NULL)", + Definition.ensureTableFor("my.table", "JSONB"), "CREATE TABLE statement not constructed correctly" + ) + + @Test + @DisplayName("ensureTable generates correctly (PostgreSQL)") + fun ensureTablePostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals("CREATE TABLE IF NOT EXISTS $tbl (data JSONB NOT NULL)", Definition.ensureTable(tbl)) + } + + @Test + @DisplayName("ensureTable generates correctly (SQLite)") + fun ensureTableSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals("CREATE TABLE IF NOT EXISTS $tbl (data TEXT NOT NULL)", Definition.ensureTable(tbl)) + } + + @Test + @DisplayName("ensureTable fails when no dialect is set") + fun ensureTableFailsUnknown() { + assertThrows { Definition.ensureTable(tbl) } + } + + @Test + @DisplayName("ensureKey generates correctly with schema") + fun ensureKeyWithSchema() = + assertEquals( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_table_key ON test.table ((data->>'id'))", + Definition.ensureKey("test.table", Dialect.POSTGRESQL), + "CREATE INDEX for key statement with schema not constructed correctly" + ) + + @Test + @DisplayName("ensureKey generates correctly without schema") + fun ensureKeyWithoutSchema() = + assertEquals( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_${tbl}_key ON $tbl ((data->>'id'))", + Definition.ensureKey(tbl, Dialect.SQLITE), + "CREATE INDEX for key statement without schema not constructed correctly" + ) + + @Test + @DisplayName("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)", + Definition.ensureIndexOn( + "test.table", "gibberish", listOf("taco", "guac DESC", "salsa ASC"), + Dialect.POSTGRESQL + ), + "CREATE INDEX for multiple field statement not constructed correctly" + ) + + @Test + @DisplayName("ensureIndexOn generates nested PostgreSQL field") + fun ensureIndexOnNestedPostgres() = + assertEquals( + "CREATE INDEX IF NOT EXISTS idx_${tbl}_nest ON $tbl ((data#>>'{a,b,c}'))", + Definition.ensureIndexOn(tbl, "nest", listOf("a.b.c"), Dialect.POSTGRESQL), + "CREATE INDEX for nested PostgreSQL field incorrect" + ) + + @Test + @DisplayName("ensureIndexOn generates nested SQLite field") + fun ensureIndexOnNestedSQLite() = + assertEquals( + "CREATE INDEX IF NOT EXISTS idx_${tbl}_nest ON $tbl ((data->'a'->'b'->>'c'))", + Definition.ensureIndexOn(tbl, "nest", listOf("a.b.c"), Dialect.SQLITE), + "CREATE INDEX for nested SQLite field incorrect" + ) +} diff --git a/src/test/kotlin/query/DocumentTest.kt b/src/test/kotlin/query/DocumentTest.kt new file mode 100644 index 0000000..5f67fde --- /dev/null +++ b/src/test/kotlin/query/DocumentTest.kt @@ -0,0 +1,147 @@ +package solutions.bitbadger.documents.query + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import solutions.bitbadger.documents.* +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Unit tests for the `Document` object + */ +@DisplayName("Document (Query)") +class DocumentTest { + + /** Test table name */ + private val tbl = "test_table" + + /** + * Clear the connection string (resets Dialect) + */ + @AfterEach + fun cleanUp() { + Configuration.dialectValue = null + } + + @Test + @DisplayName("insert generates no auto ID (PostgreSQL)") + fun insertNoAutoPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals("INSERT INTO $tbl VALUES (:data)", Document.insert(tbl)) + } + + @Test + @DisplayName("insert generates no auto ID (SQLite)") + fun insertNoAutoSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals("INSERT INTO $tbl VALUES (:data)", Document.insert(tbl)) + } + + @Test + @DisplayName("insert generates auto number (PostgreSQL)") + fun insertAutoNumberPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "INSERT INTO $tbl VALUES (:data::jsonb || ('{\"id\":' " + + "|| (SELECT COALESCE(MAX((data->>'id')::numeric), 0) + 1 FROM $tbl) || '}')::jsonb)", + Document.insert(tbl, AutoId.NUMBER) + ) + } + + @Test + @DisplayName("insert generates auto number (SQLite)") + fun insertAutoNumberSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals( + "INSERT INTO $tbl VALUES (json_set(:data, '$.id', " + + "(SELECT coalesce(max(data->>'id'), 0) + 1 FROM $tbl)))", + Document.insert(tbl, AutoId.NUMBER) + ) + } + + @Test + @DisplayName("insert generates auto UUID (PostgreSQL)") + fun insertAutoUUIDPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + val query = Document.insert(tbl, AutoId.UUID) + assertTrue( + query.startsWith("INSERT INTO $tbl VALUES (:data::jsonb || '{\"id\":\""), + "Query start not correct (actual: $query)" + ) + assertTrue(query.endsWith("\"}')"), "Query end not correct") + } + + @Test + @DisplayName("insert generates auto UUID (SQLite)") + fun insertAutoUUIDSQLite() { + Configuration.dialectValue = Dialect.SQLITE + val query = Document.insert(tbl, AutoId.UUID) + assertTrue( + query.startsWith("INSERT INTO $tbl VALUES (json_set(:data, '$.id', '"), + "Query start not correct (actual: $query)" + ) + assertTrue(query.endsWith("'))"), "Query end not correct") + } + + @Test + @DisplayName("insert generates auto random string (PostgreSQL)") + fun insertAutoRandomPostgres() { + try { + Configuration.dialectValue = Dialect.POSTGRESQL + Configuration.idStringLength = 8 + val query = Document.insert(tbl, AutoId.RANDOM_STRING) + assertTrue( + query.startsWith("INSERT INTO $tbl VALUES (:data::jsonb || '{\"id\":\""), + "Query start not correct (actual: $query)" + ) + assertTrue(query.endsWith("\"}')"), "Query end not correct") + assertEquals( + 8, + query.replace("INSERT INTO $tbl VALUES (:data::jsonb || '{\"id\":\"", "").replace("\"}')", "").length, + "Random string length incorrect" + ) + } finally { + Configuration.idStringLength = 16 + } + } + + @Test + @DisplayName("insert generates auto random string (SQLite)") + fun insertAutoRandomSQLite() { + Configuration.dialectValue = Dialect.SQLITE + val query = Document.insert(tbl, AutoId.RANDOM_STRING) + assertTrue( + query.startsWith("INSERT INTO $tbl VALUES (json_set(:data, '$.id', '"), + "Query start not correct (actual: $query)" + ) + assertTrue(query.endsWith("'))"), "Query end not correct") + assertEquals( + Configuration.idStringLength, + query.replace("INSERT INTO $tbl VALUES (json_set(:data, '$.id', '", "").replace("'))", "").length, + "Random string length incorrect" + ) + } + + @Test + @DisplayName("insert fails when no dialect is set") + fun insertFailsUnknown() { + assertThrows { Document.insert(tbl) } + } + + @Test + @DisplayName("save generates correctly") + fun save() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "INSERT INTO $tbl VALUES (:data) ON CONFLICT ((data->>'id')) DO UPDATE SET data = EXCLUDED.data", + Document.save(tbl), "INSERT ON CONFLICT UPDATE statement not constructed correctly" + ) + } + + @Test + @DisplayName("update generates successfully") + fun update() = + assertEquals("UPDATE $tbl SET data = :data", Document.update(tbl), "Update query not constructed correctly") +} diff --git a/src/test/kotlin/query/ExistsTest.kt b/src/test/kotlin/query/ExistsTest.kt new file mode 100644 index 0000000..bbef7ce --- /dev/null +++ b/src/test/kotlin/query/ExistsTest.kt @@ -0,0 +1,102 @@ +package solutions.bitbadger.documents.query + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import solutions.bitbadger.documents.* +import kotlin.test.assertEquals + +/** + * Unit tests for the `Exists` object + */ +@DisplayName("Exists (Query)") +class ExistsTest { + + /** Test table name */ + private val tbl = "test_table" + + /** + * Clear the connection string (resets Dialect) + */ + @AfterEach + fun cleanUp() { + Configuration.dialectValue = null + } + + @Test + @DisplayName("byId generates correctly (PostgreSQL)") + fun byIdPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "SELECT EXISTS (SELECT 1 FROM $tbl WHERE data->>'id' = :id) AS it", + Exists.byId(tbl), "Exists query not constructed correctly" + ) + } + + @Test + @DisplayName("byId generates correctly (SQLite)") + fun byIdSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals( + "SELECT EXISTS (SELECT 1 FROM $tbl WHERE data->>'id' = :id) AS it", + Exists.byId(tbl), "Exists query not constructed correctly" + ) + } + + @Test + @DisplayName("byFields generates correctly (PostgreSQL)") + fun byFieldsPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "SELECT EXISTS (SELECT 1 FROM $tbl WHERE (data->>'it')::numeric = :test) AS it", + Exists.byFields(tbl, listOf(Field.equal("it", 7, ":test"))), + "Exists query not constructed correctly" + ) + } + + @Test + @DisplayName("byFields generates correctly (SQLite)") + fun byFieldsSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals( + "SELECT EXISTS (SELECT 1 FROM $tbl WHERE data->>'it' = :test) AS it", + Exists.byFields(tbl, listOf(Field.equal("it", 7, ":test"))), + "Exists query not constructed correctly" + ) + } + + @Test + @DisplayName("byContains generates correctly (PostgreSQL)") + fun byContainsPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "SELECT EXISTS (SELECT 1 FROM $tbl WHERE data @> :criteria) AS it", Exists.byContains(tbl), + "Exists query not constructed correctly" + ) + } + + @Test + @DisplayName("byContains fails (SQLite)") + fun byContainsSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertThrows { Exists.byContains(tbl) } + } + + @Test + @DisplayName("byJsonPath generates correctly (PostgreSQL)") + fun byJsonPathPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "SELECT EXISTS (SELECT 1 FROM $tbl WHERE jsonb_path_exists(data, :path::jsonpath)) AS it", + Exists.byJsonPath(tbl), "Exists query not constructed correctly" + ) + } + + @Test + @DisplayName("byJsonPath fails (SQLite)") + fun byJsonPathSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertThrows { Exists.byJsonPath(tbl) } + } +} diff --git a/src/test/kotlin/query/FindTest.kt b/src/test/kotlin/query/FindTest.kt new file mode 100644 index 0000000..ca44121 --- /dev/null +++ b/src/test/kotlin/query/FindTest.kt @@ -0,0 +1,110 @@ +package solutions.bitbadger.documents.query + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import solutions.bitbadger.documents.Configuration +import solutions.bitbadger.documents.Dialect +import solutions.bitbadger.documents.DocumentException +import solutions.bitbadger.documents.Field +import kotlin.test.assertEquals + +/** + * Unit tests for the `Exists` object + */ +@DisplayName("Find (Query)") +class FindTest { + + /** Test table name */ + private val tbl = "test_table" + + /** + * Clear the connection string (resets Dialect) + */ + @AfterEach + fun cleanUp() { + Configuration.dialectValue = null + } + + @Test + @DisplayName("all generates correctly") + fun all() = + assertEquals("SELECT data FROM $tbl", Find.all(tbl), "Find query not constructed correctly") + + @Test + @DisplayName("byId generates correctly (PostgreSQL)") + fun byIdPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "SELECT data FROM $tbl WHERE data->>'id' = :id", + Find.byId(tbl), "Find query not constructed correctly" + ) + } + + @Test + @DisplayName("byId generates correctly (SQLite)") + fun byIdSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals( + "SELECT data FROM $tbl WHERE data->>'id' = :id", + Find.byId(tbl), "Find query not constructed correctly" + ) + } + + @Test + @DisplayName("byFields generates correctly (PostgreSQL)") + fun byFieldsPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "SELECT data FROM $tbl WHERE data->>'a' = :b AND (data->>'c')::numeric < :d", + Find.byFields(tbl, listOf(Field.equal("a", "", ":b"), Field.less("c", 14, ":d"))), + "Find query not constructed correctly" + ) + } + + @Test + @DisplayName("byFields generates correctly (SQLite)") + fun byFieldsSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals( + "SELECT data FROM $tbl WHERE data->>'a' = :b AND data->>'c' < :d", + Find.byFields(tbl, listOf(Field.equal("a", "", ":b"), Field.less("c", 14, ":d"))), + "Find query not constructed correctly" + ) + } + + @Test + @DisplayName("byContains generates correctly (PostgreSQL)") + fun byContainsPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "SELECT data FROM $tbl WHERE data @> :criteria", Find.byContains(tbl), + "Find query not constructed correctly" + ) + } + + @Test + @DisplayName("byContains fails (SQLite)") + fun byContainsSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertThrows { Find.byContains(tbl) } + } + + @Test + @DisplayName("byJsonPath generates correctly (PostgreSQL)") + fun byJsonPathPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "SELECT data FROM $tbl WHERE jsonb_path_exists(data, :path::jsonpath)", Find.byJsonPath(tbl), + "Find query not constructed correctly" + ) + } + + @Test + @DisplayName("byJsonPath fails (SQLite)") + fun byJsonPathSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertThrows { Find.byJsonPath(tbl) } + } +} diff --git a/src/test/kotlin/query/QueryTest.kt b/src/test/kotlin/query/QueryTest.kt new file mode 100644 index 0000000..d5344f3 --- /dev/null +++ b/src/test/kotlin/query/QueryTest.kt @@ -0,0 +1,175 @@ +package solutions.bitbadger.documents.query + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import solutions.bitbadger.documents.* +import kotlin.test.assertEquals + +/** + * Unit tests for the top-level query functions + */ +@DisplayName("Query") +class QueryTest { + + /** + * Clear the connection string (resets Dialect) + */ + @AfterEach + fun cleanUp() { + Configuration.dialectValue = null + } + + @Test + @DisplayName("statementWhere generates correctly") + fun statementWhere() = + assertEquals("x WHERE y", statementWhere("x", "y"), "Statements not combined correctly") + + @Test + @DisplayName("byId generates a numeric ID query (PostgreSQL)") + fun byIdNumericPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals("test WHERE (data->>'id')::numeric = :id", byId("test", 9)) + } + + @Test + @DisplayName("byId generates an alphanumeric ID query (PostgreSQL)") + fun byIdAlphaPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals("unit WHERE data->>'id' = :id", byId("unit", "18")) + } + + @Test + @DisplayName("byId generates ID query (SQLite)") + fun byIdSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals("yo WHERE data->>'id' = :id", byId("yo", 27)) + } + + @Test + @DisplayName("byFields generates default field query (PostgreSQL)") + fun byFieldsMultipleDefaultPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "this WHERE data->>'a' = :the_a AND (data->>'b')::numeric = :b_value", + byFields("this", listOf(Field.equal("a", "", ":the_a"), Field.equal("b", 0, ":b_value"))) + ) + } + + @Test + @DisplayName("byFields generates default field query (SQLite)") + fun byFieldsMultipleDefaultSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals( + "this WHERE data->>'a' = :the_a AND data->>'b' = :b_value", + byFields("this", listOf(Field.equal("a", "", ":the_a"), Field.equal("b", 0, ":b_value"))) + ) + } + + @Test + @DisplayName("byFields generates ANY field query (PostgreSQL)") + fun byFieldsMultipleAnyPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "that WHERE data->>'a' = :the_a OR (data->>'b')::numeric = :b_value", + byFields( + "that", listOf(Field.equal("a", "", ":the_a"), Field.equal("b", 0, ":b_value")), + FieldMatch.ANY + ) + ) + } + + @Test + @DisplayName("byFields generates ANY field query (SQLite)") + fun byFieldsMultipleAnySQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals( + "that WHERE data->>'a' = :the_a OR data->>'b' = :b_value", + byFields( + "that", listOf(Field.equal("a", "", ":the_a"), Field.equal("b", 0, ":b_value")), + FieldMatch.ANY + ) + ) + } + + @Test + @DisplayName("orderBy generates for no fields") + fun orderByNone() { + assertEquals("", orderBy(listOf(), Dialect.POSTGRESQL), "ORDER BY should have been blank (PostgreSQL)") + assertEquals("", 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'", + 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'", 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", + 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", + 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", + 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'", 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", + 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", + orderBy(listOf(Field.named("i:Test.Field ASC NULLS LAST")), Dialect.SQLITE), + "ORDER BY not constructed correctly" + ) +} diff --git a/src/test/kotlin/query/WhereTest.kt b/src/test/kotlin/query/WhereTest.kt new file mode 100644 index 0000000..c8ebfbc --- /dev/null +++ b/src/test/kotlin/query/WhereTest.kt @@ -0,0 +1,176 @@ +package solutions.bitbadger.documents.query + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import solutions.bitbadger.documents.* +import kotlin.test.assertEquals + +/** + * Unit tests for the `Where` object + */ +@DisplayName("Where (Query)") +class WhereTest { + + /** + * Clear the connection string (resets Dialect) + */ + @AfterEach + fun cleanUp() { + Configuration.dialectValue = null + } + + @Test + @DisplayName("byFields is blank when given no fields") + fun byFieldsBlankIfEmpty() = + assertEquals("", Where.byFields(listOf())) + + @Test + @DisplayName("byFields generates one numeric field (PostgreSQL)") + fun byFieldsOneFieldPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals("(data->>'it')::numeric = :that", Where.byFields(listOf(Field.equal("it", 9, ":that")))) + } + + @Test + @DisplayName("byFields generates one alphanumeric field (PostgreSQL)") + fun byFieldsOneAlphaFieldPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals("data->>'it' = :that", Where.byFields(listOf(Field.equal("it", "", ":that")))) + } + + @Test + @DisplayName("byFields generates one field (SQLite)") + fun byFieldsOneFieldSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals("data->>'it' = :that", Where.byFields(listOf(Field.equal("it", "", ":that")))) + } + + @Test + @DisplayName("byFields generates multiple fields w/ default match (PostgreSQL)") + fun byFieldsMultipleDefaultPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "data->>'1' = :one AND (data->>'2')::numeric = :two AND data->>'3' = :three", + Where.byFields( + listOf(Field.equal("1", "", ":one"), Field.equal("2", 0L, ":two"), Field.equal("3", "", ":three")) + ) + ) + } + + @Test + @DisplayName("byFields generates multiple fields w/ default match (SQLite)") + fun byFieldsMultipleDefaultSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals( + "data->>'1' = :one AND data->>'2' = :two AND data->>'3' = :three", + Where.byFields( + listOf(Field.equal("1", "", ":one"), Field.equal("2", 0L, ":two"), Field.equal("3", "", ":three")) + ) + ) + } + + @Test + @DisplayName("byFields generates multiple fields w/ ANY match (PostgreSQL)") + fun byFieldsMultipleAnyPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "data->>'1' = :one OR (data->>'2')::numeric = :two OR data->>'3' = :three", + Where.byFields( + listOf(Field.equal("1", "", ":one"), Field.equal("2", 0L, ":two"), Field.equal("3", "", ":three")), + FieldMatch.ANY + ) + ) + } + + @Test + @DisplayName("byFields generates multiple fields w/ ANY match (SQLite)") + fun byFieldsMultipleAnySQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals( + "data->>'1' = :one OR data->>'2' = :two OR data->>'3' = :three", + Where.byFields( + listOf(Field.equal("1", "", ":one"), Field.equal("2", 0L, ":two"), Field.equal("3", "", ":three")), + FieldMatch.ANY + ) + ) + } + + @Test + @DisplayName("byId generates defaults for alphanumeric key (PostgreSQL)") + fun byIdDefaultAlphaPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals("data->>'id' = :id", Where.byId(docId = "")) + } + + @Test + @DisplayName("byId generates defaults for numeric key (PostgreSQL)") + fun byIdDefaultNumericPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals("(data->>'id')::numeric = :id", Where.byId(docId = 5)) + } + + @Test + @DisplayName("byId generates defaults (SQLite)") + fun byIdDefaultSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals("data->>'id' = :id", Where.byId(docId = "")) + } + + @Test + @DisplayName("byId generates named ID (PostgreSQL)") + fun byIdDefaultNamedPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals("data->>'id' = :key", Where.byId(":key")) + } + + @Test + @DisplayName("byId generates named ID (SQLite)") + fun byIdDefaultNamedSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertEquals("data->>'id' = :key", Where.byId(":key")) + } + + @Test + @DisplayName("jsonContains generates defaults (PostgreSQL)") + fun jsonContainsDefaultPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals("data @> :criteria", Where.jsonContains()) + } + + @Test + @DisplayName("jsonContains generates named parameter (PostgreSQL)") + fun jsonContainsNamedPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals("data @> :it", Where.jsonContains(":it")) + } + + @Test + @DisplayName("jsonContains fails (SQLite)") + fun jsonContainsFailsSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertThrows { Where.jsonContains() } + } + + @Test + @DisplayName("jsonPathMatches generates defaults (PostgreSQL)") + fun jsonPathMatchDefaultPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals("jsonb_path_exists(data, :path::jsonpath)", Where.jsonPathMatches()) + } + + @Test + @DisplayName("jsonPathMatches generates named parameter (PostgreSQL)") + fun jsonPathMatchNamedPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals("jsonb_path_exists(data, :jp::jsonpath)", Where.jsonPathMatches(":jp")) + } + + @Test + @DisplayName("jsonPathMatches fails (SQLite)") + fun jsonPathFailsSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertThrows { Where.jsonPathMatches() } + } +}