Initial Development #1

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

View File

@ -78,17 +78,16 @@
<version>2.1.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>2.1.0</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
<version>2.0.20</version>
</dependency>
</dependencies>
</project>

View File

@ -1,5 +1,7 @@
package solutions.bitbadger.documents.common
import kotlin.reflect.full.*
/**
* Strategies for automatic document IDs
*/
@ -32,8 +34,46 @@ enum class AutoId {
fun generateRandomString(length: Int): String =
kotlin.random.Random.nextBytes((length + 2) / 2)
.joinToString("") { String.format("%02x", it) }
.substring(0, length - 1)
.substring(0, length)
// TODO: fun <T> needsAutoId(strategy: AutoId, document: T, idProp: String): Boolean
/**
* Determine if a document needs an automatic ID applied
*
* @param strategy The auto ID strategy for which the document is evaluated
* @param document The document whose need of an automatic ID should be determined
* @param idProp The name of the document property containing the ID
* @return `true` if the document needs an automatic ID, `false` if not
* @throws IllegalArgumentException If bad input prevents the determination
*/
fun <T> needsAutoId(strategy: AutoId, document: T, idProp: String): Boolean {
if (document == null) throw IllegalArgumentException("document cannot be null")
if (strategy == DISABLED) return false;
val id = document!!::class.memberProperties.find { it.name == idProp }
if (id == null) throw IllegalArgumentException("$idProp not found in document")
if (strategy == NUMBER) {
if (id.returnType == Byte::class.createType()) {
return id.call(document) == 0.toByte()
}
if (id.returnType == Short::class.createType()) {
return id.call(document) == 0.toShort()
}
if (id.returnType == Int::class.createType()) {
return id.call(document) == 0
}
if (id.returnType == Long::class.createType()) {
return id.call(document) == 0.toLong()
}
throw IllegalArgumentException("$idProp was not a number; cannot auto-generate number ID")
}
if (id.returnType == String::class.createType()) {
return id.call(document) == ""
}
throw IllegalArgumentException("$idProp was not a string; cannot auto-generate UUID or random string")
}
}
}

View File

@ -5,7 +5,7 @@ object Configuration {
// TODO: var jsonOpts = Json { some cool options }
/** The field in which a document's ID is stored */
var idField = "Id"
var idField = "id"
/** The automatic ID strategy to use */
var autoIdStrategy = AutoId.DISABLED

View File

@ -44,12 +44,12 @@ object Query {
* @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: List<String>, dialect: Dialect): String {
fun ensureIndexOn(tableName: String, indexName: String, fields: Collection<String>, 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"
"(" + Field.nameToPath(parts[0], dialect, FieldFormat.SQL) + ")$direction"
}
return "CREATE INDEX IF NOT EXISTS idx_${tbl}_$indexName ON $tableName ($jsonFields)"
}
@ -81,8 +81,7 @@ object Query {
* @return A query to save a document
*/
fun save(tableName: String): String =
String.format("INSERT INTO %s VALUES (:data) ON CONFLICT ((data->>'%s')) DO UPDATE SET data = EXCLUDED.data",
tableName, Configuration.idField)
"${insert(tableName)} ON CONFLICT ((data->>'${Configuration.idField}')) DO UPDATE SET data = EXCLUDED.data"
/**
* Query to count documents in a table (this query has no `WHERE` clause)
@ -137,7 +136,7 @@ object Query {
* @param dialect The SQL dialect for the generated clause
* @return An `ORDER BY` clause for the given fields
*/
fun orderBy(fields: List<Field<*>>, dialect: Dialect): String {
fun orderBy(fields: Collection<Field<*>>, dialect: Dialect): String {
if (fields.isEmpty()) return ""
val orderFields = fields.joinToString(", ") {
val (field, direction) =
@ -165,6 +164,6 @@ object Query {
}
"$path${direction ?: ""}"
}
return "ORDER BY $orderFields"
return " ORDER BY $orderFields"
}
}

View File

@ -2,16 +2,159 @@ package solutions.bitbadger.documents.common
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.assertNotNull
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
class AutoIdTest {
@Test
@DisplayName("Generates a UUID string")
fun testGenerateUUID() {
val generated = AutoId.generateUUID()
assertNotNull(generated, "The UUID string should not have been null")
assertEquals(32, generated.length, "The UUID should have been a 32-character string")
fun generateUUID() {
assertEquals(32, AutoId.generateUUID().length, "The UUID should have been a 32-character string")
}
@Test
@DisplayName("Generates a random hex character string of an even length")
fun generateRandomStringEven() {
val result = AutoId.generateRandomString(8)
assertEquals(8, result.length, "There should have been 8 characters in $result")
}
@Test
@DisplayName("Generates a random hex character string of an odd length")
fun generateRandomStringOdd() {
val result = AutoId.generateRandomString(11)
assertEquals(11, result.length, "There should have been 11 characters in $result")
}
@Test
@DisplayName("Generates different random hex character strings")
fun generateRandomStringIsRandom() {
val result1 = AutoId.generateRandomString(16)
val result2 = AutoId.generateRandomString(16)
assertNotEquals(result1, result2, "There should have been 2 different strings generated")
}
@Test
@DisplayName("needsAutoId fails for null document")
fun needsAutoIdFailsForNullDocument() {
assertThrows<IllegalArgumentException> { AutoId.needsAutoId(AutoId.DISABLED, null, "id") }
}
@Test
@DisplayName("needsAutoId fails for missing ID property")
fun needsAutoIdFailsForMissingId() {
assertThrows<IllegalArgumentException> { AutoId.needsAutoId(AutoId.UUID, IntIdClass(0), "Id") }
}
@Test
@DisplayName("needsAutoId returns false if disabled")
fun needsAutoIdFalseIfDisabled() {
assertFalse(AutoId.needsAutoId(AutoId.DISABLED, "", ""), "Disabled Auto ID should always return false")
}
@Test
@DisplayName("needsAutoId returns true for Number strategy and byte ID of 0")
fun needsAutoIdTrueForByteWithZero() {
assertTrue(AutoId.needsAutoId(AutoId.NUMBER, ByteIdClass(0), "id"), "Number Auto ID with 0 should return true")
}
@Test
@DisplayName("needsAutoId returns false for Number strategy and byte ID of non-0")
fun needsAutoIdFalseForByteWithNonZero() {
assertFalse(AutoId.needsAutoId(AutoId.NUMBER, ByteIdClass(77), "id"),
"Number Auto ID with 77 should return false")
}
@Test
@DisplayName("needsAutoId returns true for Number strategy and short ID of 0")
fun needsAutoIdTrueForShortWithZero() {
assertTrue(AutoId.needsAutoId(AutoId.NUMBER, ShortIdClass(0), "id"), "Number Auto ID with 0 should return true")
}
@Test
@DisplayName("needsAutoId returns false for Number strategy and short ID of non-0")
fun needsAutoIdFalseForShortWithNonZero() {
assertFalse(AutoId.needsAutoId(AutoId.NUMBER, ShortIdClass(31), "id"),
"Number Auto ID with 31 should return false")
}
@Test
@DisplayName("needsAutoId returns true for Number strategy and int ID of 0")
fun needsAutoIdTrueForIntWithZero() {
assertTrue(AutoId.needsAutoId(AutoId.NUMBER, IntIdClass(0), "id"), "Number Auto ID with 0 should return true")
}
@Test
@DisplayName("needsAutoId returns false for Number strategy and int ID of non-0")
fun needsAutoIdFalseForIntWithNonZero() {
assertFalse(AutoId.needsAutoId(AutoId.NUMBER, IntIdClass(6), "id"), "Number Auto ID with 6 should return false")
}
@Test
@DisplayName("needsAutoId returns true for Number strategy and long ID of 0")
fun needsAutoIdTrueForLongWithZero() {
assertTrue(AutoId.needsAutoId(AutoId.NUMBER, LongIdClass(0), "id"), "Number Auto ID with 0 should return true")
}
@Test
@DisplayName("needsAutoId returns false for Number strategy and long ID of non-0")
fun needsAutoIdFalseForLongWithNonZero() {
assertFalse(AutoId.needsAutoId(AutoId.NUMBER, IntIdClass(2), "id"), "Number Auto ID with 2 should return false")
}
@Test
@DisplayName("needsAutoId fails for Number strategy and non-number ID")
fun needsAutoIdFailsForNumberWithStringId() {
assertThrows<IllegalArgumentException> { AutoId.needsAutoId(AutoId.NUMBER, StringIdClass(""), "id") }
}
@Test
@DisplayName("needsAutoId returns true for UUID strategy and blank ID")
fun needsAutoIdTrueForUUIDWithBlank() {
assertTrue(AutoId.needsAutoId(AutoId.UUID, StringIdClass(""), "id"),
"UUID Auto ID with blank should return true")
}
@Test
@DisplayName("needsAutoId returns false for UUID strategy and non-blank ID")
fun needsAutoIdFalseForUUIDNotBlank() {
assertFalse(AutoId.needsAutoId(AutoId.UUID, StringIdClass("howdy"), "id"),
"UUID Auto ID with non-blank should return false")
}
@Test
@DisplayName("needsAutoId fails for UUID strategy and non-string ID")
fun needsAutoIdFailsForUUIDNonString() {
assertThrows<IllegalArgumentException> { AutoId.needsAutoId(AutoId.UUID, IntIdClass(5), "id") }
}
@Test
@DisplayName("needsAutoId returns true for Random String strategy and blank ID")
fun needsAutoIdTrueForRandomWithBlank() {
assertTrue(AutoId.needsAutoId(AutoId.RANDOM_STRING, StringIdClass(""), "id"),
"Random String Auto ID with blank should return true")
}
@Test
@DisplayName("needsAutoId returns false for Random String strategy and non-blank ID")
fun needsAutoIdFalseForRandomNotBlank() {
assertFalse(AutoId.needsAutoId(AutoId.RANDOM_STRING, StringIdClass("full"), "id"),
"Random String Auto ID with non-blank should return false")
}
@Test
@DisplayName("needsAutoId fails for Random String strategy and non-string ID")
fun needsAutoIdFailsForRandomNonString() {
assertThrows<IllegalArgumentException> { AutoId.needsAutoId(AutoId.RANDOM_STRING, ShortIdClass(55), "id") }
}
}
data class ByteIdClass(var id: Byte)
data class ShortIdClass(var id: Short)
data class IntIdClass(var id: Int)
data class LongIdClass(var id: Long)
data class StringIdClass(var id: String)

View File

@ -0,0 +1,26 @@
package solutions.bitbadger.documents.common
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class ConfigurationTest {
@Test
@DisplayName("Default ID field is `id`")
fun defaultIdField() {
assertEquals("id", Configuration.idField, "Default ID field incorrect")
}
@Test
@DisplayName("Default Auto ID strategy is `DISABLED`")
fun defaultAutoId() {
assertEquals(AutoId.DISABLED, Configuration.autoIdStrategy, "Default Auto ID strategy should be `disabled`")
}
@Test
@DisplayName("Default ID string length should be 16")
fun defaultIdStringLength() {
assertEquals(16, Configuration.idStringLength, "Default ID string length should be 16")
}
}

View File

@ -0,0 +1,181 @@
package solutions.bitbadger.documents.common
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class QueryTest {
/** Test table name */
private val tbl = "test_table"
@Test
@DisplayName("statementWhere generates correctly")
fun statementWhere() {
assertEquals("x WHERE y", Query.statementWhere("x", "y"), "Statements not combined correctly")
}
@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.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")
}
@Test
@DisplayName("insert generates correctly")
fun insert() {
assertEquals("INSERT INTO $tbl VALUES (:data)", Query.insert(tbl), "INSERT statement not constructed correctly")
}
@Test
@DisplayName("save generates correctly")
fun save() {
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 generates correctly")
fun count() {
assertEquals("SELECT COUNT(*) AS it FROM $tbl", Query.count(tbl), "Count query not constructed correctly")
}
@Test
@DisplayName("exists generates correctly")
fun exists() {
assertEquals("SELECT EXISTS (SELECT 1 FROM $tbl WHERE turkey) AS it", Query.exists(tbl, "turkey"),
"Exists query not constructed correctly")
}
@Test
@DisplayName("find generates correctly")
fun find() {
assertEquals("SELECT data FROM $tbl", Query.find(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")
}
}