From 93c1b2740d7abf0e13da37d6336e7fa66e2551b4 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 13 Feb 2025 12:16:40 -0500 Subject: [PATCH] Migrate common lib (auto ID pending) --- .gitignore | 3 + solutions.bitbadger.documents.iml | 8 + src/common/pom.xml | 88 ++++++++++ src/common/src/main/kotlin/AutoId.kt | 39 +++++ src/common/src/main/kotlin/Comparison.kt | 9 ++ src/common/src/main/kotlin/Configuration.kt | 15 ++ src/common/src/main/kotlin/Dialect.kt | 11 ++ src/common/src/main/kotlin/Field.kt | 132 +++++++++++++++ src/common/src/main/kotlin/FieldFormat.kt | 11 ++ src/common/src/main/kotlin/FieldMatch.kt | 11 ++ src/common/src/main/kotlin/Main.kt | 14 ++ src/common/src/main/kotlin/Op.kt | 29 ++++ src/common/src/main/kotlin/ParameterName.kt | 18 +++ src/common/src/main/kotlin/Query.kt | 170 ++++++++++++++++++++ 14 files changed, 558 insertions(+) create mode 100644 solutions.bitbadger.documents.iml create mode 100644 src/common/pom.xml create mode 100644 src/common/src/main/kotlin/AutoId.kt create mode 100644 src/common/src/main/kotlin/Comparison.kt create mode 100644 src/common/src/main/kotlin/Configuration.kt create mode 100644 src/common/src/main/kotlin/Dialect.kt create mode 100644 src/common/src/main/kotlin/Field.kt create mode 100644 src/common/src/main/kotlin/FieldFormat.kt create mode 100644 src/common/src/main/kotlin/FieldMatch.kt create mode 100644 src/common/src/main/kotlin/Main.kt create mode 100644 src/common/src/main/kotlin/Op.kt create mode 100644 src/common/src/main/kotlin/ParameterName.kt create mode 100644 src/common/src/main/kotlin/Query.kt diff --git a/.gitignore b/.gitignore index 0296a22..e36da4c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ replay_pid* # Kotlin Gradle plugin data, see https://kotlinlang.org/docs/whatsnew20.html#new-directory-for-kotlin-data-in-gradle-projects .kotlin/ + +# Temporary output directories +**/target diff --git a/solutions.bitbadger.documents.iml b/solutions.bitbadger.documents.iml new file mode 100644 index 0000000..9a5cfce --- /dev/null +++ b/solutions.bitbadger.documents.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/common/pom.xml b/src/common/pom.xml new file mode 100644 index 0000000..2bc4cdc --- /dev/null +++ b/src/common/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + solutions.bitbadger.documents + common + 4.0-ALPHA + + + UTF-8 + official + 1.8 + + + + + mavenCentral + https://repo1.maven.org/maven2/ + + + + + src/main/kotlin + src/test/kotlin + + + org.jetbrains.kotlin + kotlin-maven-plugin + 2.1.0 + + + compile + compile + + compile + + + + test-compile + test-compile + + test-compile + + + + + + maven-surefire-plugin + 2.22.2 + + + maven-failsafe-plugin + 2.22.2 + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + MainKt + + + + + + + + org.jetbrains.kotlin + kotlin-test-junit5 + 2.1.0 + test + + + org.junit.jupiter + junit-jupiter + 5.10.0 + test + + + org.jetbrains.kotlin + kotlin-stdlib + 2.1.0 + + + + \ No newline at end of file diff --git a/src/common/src/main/kotlin/AutoId.kt b/src/common/src/main/kotlin/AutoId.kt new file mode 100644 index 0000000..f42e5a0 --- /dev/null +++ b/src/common/src/main/kotlin/AutoId.kt @@ -0,0 +1,39 @@ +package solutions.bitbadger.documents.common + +/** + * Strategies for automatic document IDs + */ +enum class AutoId { + /** No automatic IDs will be generated */ + DISABLED, + /** Generate a `MAX`-plus-1 numeric ID */ + NUMBER, + /** Generate a `UUID` string ID */ + UUID, + /** Generate a random hex character string ID */ + RANDOM_STRING; + + companion object { + + /** + * Generate a `UUID` string + * + * @return A `UUID` string + */ + fun generateUUID(): String = + java.util.UUID.randomUUID().toString().replace("-", "") + + /** + * Generate a string of random hex characters + * + * @param length The length of the string + * @return A string of random hex characters of the requested length + */ + fun generateRandomString(length: Int): String = + kotlin.random.Random.nextBytes((length + 2) / 2) + .joinToString("") { String.format("%02x", it) } + .substring(0, length - 1) + + // TODO: fun needsAutoId(strategy: AutoId, document: T, idProp: String): Boolean + } +} diff --git a/src/common/src/main/kotlin/Comparison.kt b/src/common/src/main/kotlin/Comparison.kt new file mode 100644 index 0000000..d6f7c24 --- /dev/null +++ b/src/common/src/main/kotlin/Comparison.kt @@ -0,0 +1,9 @@ +package solutions.bitbadger.documents.common + +/** + * A comparison against a field in a JSON document + * + * @property op The operation for the field comparison + * @property value The value against which the comparison will be made + */ +class Comparison(val op: Op, val value: T) diff --git a/src/common/src/main/kotlin/Configuration.kt b/src/common/src/main/kotlin/Configuration.kt new file mode 100644 index 0000000..1d035f0 --- /dev/null +++ b/src/common/src/main/kotlin/Configuration.kt @@ -0,0 +1,15 @@ +package solutions.bitbadger.documents.common + +object Configuration { + + // TODO: var jsonOpts = Json { some cool options } + + /** The field in which a document's ID is stored */ + var idField = "Id" + + /** The automatic ID strategy to use */ + var autoIdStrategy = AutoId.DISABLED + + /** The length of automatic random hex character string */ + var idStringLength = 16 +} diff --git a/src/common/src/main/kotlin/Dialect.kt b/src/common/src/main/kotlin/Dialect.kt new file mode 100644 index 0000000..bdf686c --- /dev/null +++ b/src/common/src/main/kotlin/Dialect.kt @@ -0,0 +1,11 @@ +package solutions.bitbadger.documents.common + +/** + * The SQL dialect to use when building queries + */ +enum class Dialect { + /** PostgreSQL */ + POSTGRESQL, + /** SQLite */ + SQLITE +} diff --git a/src/common/src/main/kotlin/Field.kt b/src/common/src/main/kotlin/Field.kt new file mode 100644 index 0000000..65db8ea --- /dev/null +++ b/src/common/src/main/kotlin/Field.kt @@ -0,0 +1,132 @@ +package solutions.bitbadger.documents.common + +/** + * A field and its comparison + * + * @property name The name of the field in the JSON document + * @property comparison The comparison to apply against the field + * @property parameterName The name of the parameter to use in the query (optional, generated if missing) + * @property qualifier A table qualifier to use to address the `data` field (useful for multi-table queries) + */ +class Field( + val name: String, + val comparison: Comparison, + val parameterName: String? = null, + val qualifier: String? = null) { + + /** + * Get the path for this field + * + * @param dialect The SQL dialect to use for the path to the JSON field + * @param format Whether the value should be retrieved as JSON or SQL (optional, default SQL) + * @return The path for the field + */ + fun path(dialect: Dialect, format: FieldFormat = FieldFormat.SQL): String = + (if (qualifier == null) "" else "${qualifier}.") + nameToPath(name, dialect, format) + + companion object { + /** + * Create a field equality comparison + * + * @param name The name of the field to be compared + * @param value The value for the comparison + * @return A `Field` with the given comparison + */ + fun Equal(name: String, value: T): Field = + Field(name, Comparison(Op.EQUAL, value)) + + /** + * Create a field greater-than comparison + * + * @param name The name of the field to be compared + * @param value The value for the comparison + * @return A `Field` with the given comparison + */ + fun Greater(name: String, value: T): Field = + Field(name, Comparison(Op.GREATER, value)) + + /** + * Create a field greater-than-or-equal-to comparison + * + * @param name The name of the field to be compared + * @param value The value for the comparison + * @return A `Field` with the given comparison + */ + fun GreaterOrEqual(name: String, value: T): Field = + Field(name, Comparison(Op.GREATER_OR_EQUAL, value)) + + /** + * Create a field less-than comparison + * + * @param name The name of the field to be compared + * @param value The value for the comparison + * @return A `Field` with the given comparison + */ + fun Less(name: String, value: T): Field = + Field(name, Comparison(Op.LESS, value)) + + /** + * Create a field less-than-or-equal-to comparison + * + * @param name The name of the field to be compared + * @param value The value for the comparison + * @return A `Field` with the given comparison + */ + fun LessOrEqual(name: String, value: T): Field = + Field(name, Comparison(Op.LESS_OR_EQUAL, value)) + + /** + * Create a field inequality comparison + * + * @param name The name of the field to be compared + * @param value The value for the comparison + * @return A `Field` with the given comparison + */ + fun NotEqual(name: String, value: T): Field = + Field(name, Comparison(Op.NOT_EQUAL, value)) + + /** + * Create a field range comparison + * + * @param name The name of the field to be compared + * @param minValue The lower value for the comparison + * @param maxValue The upper value for the comparison + * @return A `Field` with the given comparison + */ + fun Between(name: String, minValue: T, maxValue: T): Field> = + Field(name, Comparison(Op.BETWEEN, Pair(minValue, maxValue))) + + fun In(name: String, values: List): Field> = + Field(name, Comparison(Op.IN, values)) + + fun InArray(name: String, tableName: String, values: List): Field>> = + Field(name, Comparison(Op.IN_ARRAY, Pair(tableName, values))) + + fun Exists(name: String): Field = + Field(name, Comparison(Op.EXISTS, "")) + + fun NotExists(name: String): Field = + Field(name, Comparison(Op.NOT_EXISTS, "")) + + fun Named(name: String): Field = + Field(name, Comparison(Op.EQUAL, "")) + + fun nameToPath(name: String, dialect: Dialect, format: FieldFormat): String { + val path = StringBuilder("data") + val extra = if (format == FieldFormat.SQL) ">" else "" + if (name.indexOf('.') > -1) { + if (dialect == Dialect.POSTGRESQL) { + path.append("#>", extra, "'{", name.replace('.', ','), "}'") + } else { + val names = mutableListOf(name.split('.')) + val last = names.removeLast() + names.forEach { path.append("->'", it, "'") } + path.append("->", extra, "'", last, "'") + } + } else { + path.append("->", extra, "'", name, "'") + } + return path.toString() + } + } +} \ No newline at end of file diff --git a/src/common/src/main/kotlin/FieldFormat.kt b/src/common/src/main/kotlin/FieldFormat.kt new file mode 100644 index 0000000..02d4c20 --- /dev/null +++ b/src/common/src/main/kotlin/FieldFormat.kt @@ -0,0 +1,11 @@ +package solutions.bitbadger.documents.common + +/** + * The data format for a document field retrieval + */ +enum class FieldFormat { + /** Retrieve the field as a SQL value (string in PostgreSQL, best guess in SQLite */ + SQL, + /** Retrieve the field as a JSON value */ + JSON +} diff --git a/src/common/src/main/kotlin/FieldMatch.kt b/src/common/src/main/kotlin/FieldMatch.kt new file mode 100644 index 0000000..13a7610 --- /dev/null +++ b/src/common/src/main/kotlin/FieldMatch.kt @@ -0,0 +1,11 @@ +package solutions.bitbadger.documents.common + +/** + * How fields should be matched in by-field queries + */ +enum class FieldMatch(sql: String) { + /** Match any of the field criteria (`OR`) */ + ANY("OR"), + /** Match all the field criteria (`AND`) */ + ALL("AND"), +} diff --git a/src/common/src/main/kotlin/Main.kt b/src/common/src/main/kotlin/Main.kt new file mode 100644 index 0000000..7265465 --- /dev/null +++ b/src/common/src/main/kotlin/Main.kt @@ -0,0 +1,14 @@ +//TIP To Run code, press or +// click the icon in the gutter. +fun main() { + val name = "Kotlin" + //TIP Press with your caret at the highlighted text + // to see how IntelliJ IDEA suggests fixing it. + println("Hello, " + name + "!") + + for (i in 1..5) { + //TIP Press to start debugging your code. We have set one breakpoint + // for you, but you can always add more by pressing . + println("i = $i") + } +} \ No newline at end of file diff --git a/src/common/src/main/kotlin/Op.kt b/src/common/src/main/kotlin/Op.kt new file mode 100644 index 0000000..0fbf185 --- /dev/null +++ b/src/common/src/main/kotlin/Op.kt @@ -0,0 +1,29 @@ +package solutions.bitbadger.documents.common + +/** + * A comparison operator used for fields + */ +enum class Op(sql: String) { + /** Compare using equality */ + EQUAL("="), + /** Compare using greater-than */ + GREATER(">"), + /** Compare using greater-than-or-equal-to */ + GREATER_OR_EQUAL(">="), + /** Compare using less-than */ + LESS("<"), + /** Compare using less-than-or-equal-to */ + LESS_OR_EQUAL("<="), + /** Compare using inequality */ + NOT_EQUAL("<>"), + /** Compare between two values */ + BETWEEN("BETWEEN"), + /** Compare existence in a list of values */ + IN("IN"), + /** Compare overlap between an array and a list of values */ + IN_ARRAY("?|"), + /** Compare existence */ + EXISTS("IS NOT NULL"), + /** Compare nonexistence */ + NOT_EXISTS("IS NULL") +} diff --git a/src/common/src/main/kotlin/ParameterName.kt b/src/common/src/main/kotlin/ParameterName.kt new file mode 100644 index 0000000..566dca0 --- /dev/null +++ b/src/common/src/main/kotlin/ParameterName.kt @@ -0,0 +1,18 @@ +package solutions.bitbadger.documents.common + +/** + * Derive parameter names; each instance wraps a counter to provide names for anonymous fields + */ +class ParameterName { + + private var currentIdx = 0 + + /** + * Derive the parameter name from the current possibly-null string + * + * @param paramName The name of the parameter as specified by the field + * @return The name from the field, if present, or a derived name if missing + */ + fun derive(paramName: String?): String = + paramName ?: ":field${currentIdx++}" +} diff --git a/src/common/src/main/kotlin/Query.kt b/src/common/src/main/kotlin/Query.kt new file mode 100644 index 0000000..4eae107 --- /dev/null +++ b/src/common/src/main/kotlin/Query.kt @@ -0,0 +1,170 @@ +package solutions.bitbadger.documents.common + +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): String = + "$statement WHERE $where" + + 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): String = + "CREATE TABLE IF NOT EXISTS $tableName (data $dataType NOT NULL)" + + /** + * 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): Pair { + val parts = tableName.split('.') + 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 + * + * @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: List, 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): String = + 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): String = + "INSERT INTO $tableName VALUES (:data)" + + /** + * 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): String = + String.format("INSERT INTO %s VALUES (:data) ON CONFLICT ((data->>'%s')) DO UPDATE SET data = EXCLUDED.data", + tableName, Configuration.idField) + + /** + * Query to count documents in a table (this query has no `WHERE` clause) + * + * @param tableName The table in which to count documents (may include schema) + * @return A query to count documents + */ + fun count(tableName: String): String = + "SELECT COUNT(*) AS it FROM $tableName" + + /** + * 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 + */ + fun exists(tableName: String, where: String): String = + "SELECT EXISTS (SELECT 1 FROM $tableName WHERE $where) AS it" + + /** + * Query to select documents from a table (this query has no `WHERE` clause) + * + * @param tableName The table from which documents should be found (may include schema) + * @return A query to retrieve documents + */ + fun find(tableName: String): String = + "SELECT data FROM $tableName" + + /** + * 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): String = + "UPDATE $tableName SET data = :data" + + /** + * Query to delete documents from a table (this query has no `WHERE` clause) + * + * @param tableName The table in which documents should be deleted (may include schema) + * @return A query to delete documents + */ + fun delete(tableName: String): String = + "DELETE FROM $tableName" + + /** + * 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: List>, 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 = + if (field.name.startsWith("n:")) { + val fld = Field.Named(field.name.substring(2)) + when (dialect) { + Dialect.POSTGRESQL -> "(${fld.path(dialect)})::numeric" + Dialect.SQLITE -> fld.path(dialect) + } + } else if (field.name.startsWith("i:")) { + val p = Field.Named(field.name.substring(2)).path(dialect) + when (dialect) { + Dialect.POSTGRESQL -> "LOWER($p)" + Dialect.SQLITE -> "$p COLLATE NOCASE" + } + } else { + field.path(dialect) + } + "$path${direction ?: ""}" + } + return "ORDER BY $orderFields" + } +}