Initial Development #1

Merged
danieljsummers merged 88 commits from v1-rc into main 2025-04-16 01:29:20 +00:00
14 changed files with 558 additions and 0 deletions
Showing only changes of commit 93c1b2740d - Show all commits

3
.gitignore vendored
View File

@ -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 Gradle plugin data, see https://kotlinlang.org/docs/whatsnew20.html#new-directory-for-kotlin-data-in-gradle-projects
.kotlin/ .kotlin/
# Temporary output directories
**/target

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="GENERAL_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

88
src/common/pom.xml Normal file
View File

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>solutions.bitbadger.documents</groupId>
<artifactId>common</artifactId>
<version>4.0-ALPHA</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<kotlin.code.style>official</kotlin.code.style>
<kotlin.compiler.jvmTarget>1.8</kotlin.compiler.jvmTarget>
</properties>
<repositories>
<repository>
<id>mavenCentral</id>
<url>https://repo1.maven.org/maven2/</url>
</repository>
</repositories>
<build>
<sourceDirectory>src/main/kotlin</sourceDirectory>
<testSourceDirectory>src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>2.1.0</version>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<version>2.22.2</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>1.6.0</version>
<configuration>
<mainClass>MainKt</mainClass>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test-junit5</artifactId>
<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>
</dependencies>
</project>

View File

@ -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 <T> needsAutoId(strategy: AutoId, document: T, idProp: String): Boolean
}
}

View File

@ -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<T>(val op: Op, val value: T)

View File

@ -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
}

View File

@ -0,0 +1,11 @@
package solutions.bitbadger.documents.common
/**
* The SQL dialect to use when building queries
*/
enum class Dialect {
/** PostgreSQL */
POSTGRESQL,
/** SQLite */
SQLITE
}

View File

@ -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<T>(
val name: String,
val comparison: Comparison<T>,
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 <T> Equal(name: String, value: T): Field<T> =
Field<T>(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 <T> Greater(name: String, value: T): Field<T> =
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 <T> GreaterOrEqual(name: String, value: T): Field<T> =
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 <T> Less(name: String, value: T): Field<T> =
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 <T> LessOrEqual(name: String, value: T): Field<T> =
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 <T> NotEqual(name: String, value: T): Field<T> =
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 <T> Between(name: String, minValue: T, maxValue: T): Field<Pair<T, T>> =
Field(name, Comparison(Op.BETWEEN, Pair(minValue, maxValue)))
fun <T> In(name: String, values: List<T>): Field<List<T>> =
Field(name, Comparison(Op.IN, values))
fun <T> InArray(name: String, tableName: String, values: List<T>): Field<Pair<String, List<T>>> =
Field(name, Comparison(Op.IN_ARRAY, Pair(tableName, values)))
fun Exists(name: String): Field<String> =
Field(name, Comparison(Op.EXISTS, ""))
fun NotExists(name: String): Field<String> =
Field(name, Comparison(Op.NOT_EXISTS, ""))
fun Named(name: String): Field<String> =
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()
}
}
}

View File

@ -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
}

View File

@ -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"),
}

View File

@ -0,0 +1,14 @@
//TIP To <b>Run</b> code, press <shortcut actionId="Run"/> or
// click the <icon src="AllIcons.Actions.Execute"/> icon in the gutter.
fun main() {
val name = "Kotlin"
//TIP Press <shortcut actionId="ShowIntentionActions"/> 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 <shortcut actionId="Debug"/> to start debugging your code. We have set one <icon src="AllIcons.Debugger.Db_set_breakpoint"/> breakpoint
// for you, but you can always add more by pressing <shortcut actionId="ToggleLineBreakpoint"/>.
println("i = $i")
}
}

View File

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

View File

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

View File

@ -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<String, String> {
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<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"
}
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<Field<*>>, 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<Field<*>, 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"
}
}