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