Initial Development #1

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

View File

@ -46,7 +46,7 @@ object Query {
fun jsonContains(parameterName: String = ":criteria") = fun jsonContains(parameterName: String = ":criteria") =
when (Configuration.dialect("create containment WHERE clause")) { when (Configuration.dialect("create containment WHERE clause")) {
Dialect.POSTGRESQL -> "data @> $parameterName" Dialect.POSTGRESQL -> "data @> $parameterName"
Dialect.SQLITE -> throw DocumentException("JSON containment is not supported") Dialect.SQLITE -> throw DocumentException("JSON containment is not supported")
} }
/** /**
@ -59,7 +59,7 @@ object Query {
fun jsonPathMatches(parameterName: String = ":path") = fun jsonPathMatches(parameterName: String = ":path") =
when (Configuration.dialect("create JSON path match WHERE clause")) { when (Configuration.dialect("create JSON path match WHERE clause")) {
Dialect.POSTGRESQL -> "jsonb_path_exists(data, $parameterName::jsonpath)" Dialect.POSTGRESQL -> "jsonb_path_exists(data, $parameterName::jsonpath)"
Dialect.SQLITE -> throw DocumentException("JSON path match is not supported") Dialect.SQLITE -> throw DocumentException("JSON path match is not supported")
} }
} }
@ -77,12 +77,12 @@ object Query {
* Create a query on JSON fields * Create a query on JSON fields
* *
* @param statement The SQL statement to be run against matching fields * @param statement The SQL statement to be run against matching fields
* @param howMatched Whether to match any or all of the field conditions
* @param fields The field conditions to be matched * @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 * @return A query addressing documents by field matching conditions
*/ */
fun byFields(statement: String, howMatched: FieldMatch, fields: Collection<Field<*>>) = fun byFields(statement: String, fields: Collection<Field<*>>, howMatched: FieldMatch? = null) =
Query.statementWhere(statement, Where.byFields(fields, howMatched)) statementWhere(statement, Where.byFields(fields, howMatched))
/** /**
* Functions to create queries to define tables and indexes * Functions to create queries to define tables and indexes
@ -108,7 +108,7 @@ object Query {
fun ensureTable(tableName: String) = fun ensureTable(tableName: String) =
when (Configuration.dialect("create table creation query")) { when (Configuration.dialect("create table creation query")) {
Dialect.POSTGRESQL -> ensureTableFor(tableName, "JSONB") Dialect.POSTGRESQL -> ensureTableFor(tableName, "JSONB")
Dialect.SQLITE -> ensureTableFor(tableName, "TEXT") Dialect.SQLITE -> ensureTableFor(tableName, "TEXT")
} }
/** /**
@ -117,10 +117,8 @@ object Query {
* @param tableName The name of the table, possibly with a schema * @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 * @return A pair with the first item as the schema and the second as the table name
*/ */
private fun splitSchemaAndTable(tableName: String): Pair<String, String> { private fun splitSchemaAndTable(tableName: String) =
val parts = tableName.split('.') tableName.split('.').let { if (it.size == 1) Pair("", tableName) else Pair(it[0], it[1]) }
return if (parts.size == 1) Pair("", tableName) else Pair(parts[0], parts[1])
}
/** /**
* SQL statement to create an index on one or more fields in a JSON document * SQL statement to create an index on one or more fields in a JSON document

View File

@ -3,7 +3,9 @@ package solutions.bitbadger.documents
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue
class QueryTest { class QueryTest {
@ -130,6 +132,105 @@ class QueryTest {
assertEquals("data->>'id' = :key", Query.Where.byId<String>(":key")) assertEquals("data->>'id' = :key", Query.Where.byId<String>(":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<DocumentException> { 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<DocumentException> { 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 ~~~ // ~~~ Definition ~~~
@Test @Test
@ -138,6 +239,26 @@ class QueryTest {
assertEquals("CREATE TABLE IF NOT EXISTS my.table (data JSONB NOT NULL)", assertEquals("CREATE TABLE IF NOT EXISTS my.table (data JSONB NOT NULL)",
Query.Definition.ensureTableFor("my.table", "JSONB"), "CREATE TABLE statement not constructed correctly") 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<DocumentException> { Query.Definition.ensureTable(tbl) }
}
@Test @Test
@DisplayName("Definition.ensureKey generates correctly with schema") @DisplayName("Definition.ensureKey generates correctly with schema")
fun ensureKeyWithSchema() = fun ensureKeyWithSchema() =
@ -175,11 +296,97 @@ class QueryTest {
Query.Definition.ensureIndexOn(tbl, "nest", listOf("a.b.c"), Dialect.SQLITE), Query.Definition.ensureIndexOn(tbl, "nest", listOf("a.b.c"), Dialect.SQLITE),
"CREATE INDEX for nested SQLite field incorrect") "CREATE INDEX for nested SQLite field incorrect")
// ~~~ root functions ~~~
@Test @Test
@DisplayName("insert generates correctly") @DisplayName("insert generates no auto ID (PostgreSQL)")
fun insert() { fun insertNoAutoPostgres() {
Configuration.connectionString = pg Configuration.connectionString = pg
assertEquals("INSERT INTO $tbl VALUES (:data)", Query.insert(tbl), "INSERT statement not constructed correctly") 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<DocumentException> { Query.insert(tbl) }
} }
@Test @Test