diff --git a/src/common/pom.xml b/src/common/pom.xml
index 110ad24..8c65730 100644
--- a/src/common/pom.xml
+++ b/src/common/pom.xml
@@ -78,17 +78,16 @@
2.1.0
test
-
- org.junit.jupiter
- junit-jupiter
- 5.10.0
- test
-
org.jetbrains.kotlin
kotlin-stdlib
2.1.0
+
+ org.jetbrains.kotlin
+ kotlin-reflect
+ 2.0.20
+
\ No newline at end of file
diff --git a/src/common/src/main/kotlin/AutoId.kt b/src/common/src/main/kotlin/AutoId.kt
index f42e5a0..cf34802 100644
--- a/src/common/src/main/kotlin/AutoId.kt
+++ b/src/common/src/main/kotlin/AutoId.kt
@@ -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 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 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")
+ }
}
}
diff --git a/src/common/src/main/kotlin/Configuration.kt b/src/common/src/main/kotlin/Configuration.kt
index 1d035f0..ca82639 100644
--- a/src/common/src/main/kotlin/Configuration.kt
+++ b/src/common/src/main/kotlin/Configuration.kt
@@ -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
diff --git a/src/common/src/main/kotlin/Query.kt b/src/common/src/main/kotlin/Query.kt
index 28e553d..31e6a67 100644
--- a/src/common/src/main/kotlin/Query.kt
+++ b/src/common/src/main/kotlin/Query.kt
@@ -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, dialect: Dialect): String {
+ 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"
+ "(" + 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>, dialect: Dialect): String {
+ fun orderBy(fields: Collection>, 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"
}
}
diff --git a/src/common/src/test/kotlin/AutoIdTest.kt b/src/common/src/test/kotlin/AutoIdTest.kt
index 9552a81..038d322 100644
--- a/src/common/src/test/kotlin/AutoIdTest.kt
+++ b/src/common/src/test/kotlin/AutoIdTest.kt
@@ -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")
}
-}
\ No newline at end of file
+
+ @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 { AutoId.needsAutoId(AutoId.DISABLED, null, "id") }
+ }
+
+ @Test
+ @DisplayName("needsAutoId fails for missing ID property")
+ fun needsAutoIdFailsForMissingId() {
+ assertThrows { 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 { 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 { 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 { 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)
diff --git a/src/common/src/test/kotlin/ConfigurationTest.kt b/src/common/src/test/kotlin/ConfigurationTest.kt
new file mode 100644
index 0000000..29a2a61
--- /dev/null
+++ b/src/common/src/test/kotlin/ConfigurationTest.kt
@@ -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")
+ }
+}
diff --git a/src/common/src/test/kotlin/QueryTest.kt b/src/common/src/test/kotlin/QueryTest.kt
new file mode 100644
index 0000000..565243d
--- /dev/null
+++ b/src/common/src/test/kotlin/QueryTest.kt
@@ -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")
+ }
+}