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") + } +}