diff --git a/src/integration-test/kotlin/common/Document.kt b/src/integration-test/kotlin/common/Document.kt index e17a091..c0fdc51 100644 --- a/src/integration-test/kotlin/common/Document.kt +++ b/src/integration-test/kotlin/common/Document.kt @@ -1,8 +1,7 @@ package solutions.bitbadger.documents.common import solutions.bitbadger.documents.* -import kotlin.test.assertEquals -import kotlin.test.fail +import kotlin.test.* /** * Integration tests for the `Document` object / `insert`, `save`, `update` connection extension functions @@ -85,4 +84,44 @@ object Document { Configuration.idStringLength = 16 } } + + fun saveMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + db.conn.save(TEST_TABLE, JsonDocument("two", numValue = 44)) + val doc = db.conn.findById(TEST_TABLE, "two") + assertNotNull(doc, "There should have been a document returned") + assertEquals("two", doc.id, "An incorrect document was returned") + assertEquals("", doc.value, "The \"value\" field was not updated") + assertEquals(44, doc.numValue, "The \"numValue\" field was not updated") + assertNull(doc.sub, "The \"sub\" field was not updated") + } + + fun saveNoMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + db.conn.save(TEST_TABLE, JsonDocument("test", sub = SubDocument("a", "b"))) + assertNotNull( + db.conn.findById(TEST_TABLE, "test"), + "The test document should have been saved" + ) + } + + fun updateMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + db.conn.update(TEST_TABLE, "one", JsonDocument("one", "howdy", 8, SubDocument("y", "z"))) + val doc = db.conn.findById(TEST_TABLE, "one") + assertNotNull(doc, "There should have been a document returned") + assertEquals("one", doc.id, "An incorrect document was returned") + assertEquals("howdy", doc.value, "The \"value\" field was not updated") + assertEquals(8, doc.numValue, "The \"numValue\" field was not updated") + assertNotNull(doc.sub, "The sub-document should not be null") + assertEquals("y", doc.sub.foo, "The sub-document \"foo\" field was not updated") + assertEquals("z", doc.sub.bar, "The sub-document \"bar\" field was not updated") + } + + fun updateNoMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + assertFalse { db.conn.existsById(TEST_TABLE, "two-hundred") } + db.conn.update(TEST_TABLE, "two-hundred", JsonDocument("two-hundred", numValue = 200)) + assertFalse { db.conn.existsById(TEST_TABLE, "two-hundred") } + } } diff --git a/src/integration-test/kotlin/postgresql/DocumentIT.kt b/src/integration-test/kotlin/postgresql/DocumentIT.kt index ee318a7..df3ac17 100644 --- a/src/integration-test/kotlin/postgresql/DocumentIT.kt +++ b/src/integration-test/kotlin/postgresql/DocumentIT.kt @@ -34,4 +34,24 @@ class DocumentIT { @DisplayName("insert succeeds with random string auto ID") fun insertStringAutoId() = PgDB().use(Document::insertStringAutoId) + + @Test + @DisplayName("save updates an existing document") + fun saveMatch() = + PgDB().use(Document::saveMatch) + + @Test + @DisplayName("save inserts a new document") + fun saveNoMatch() = + PgDB().use(Document::saveNoMatch) + + @Test + @DisplayName("update replaces an existing document") + fun updateMatch() = + PgDB().use(Document::updateMatch) + + @Test + @DisplayName("update succeeds when no document exists") + fun updateNoMatch() = + PgDB().use(Document::updateNoMatch) } diff --git a/src/integration-test/kotlin/sqlite/DocumentIT.kt b/src/integration-test/kotlin/sqlite/DocumentIT.kt index f8c00d2..edb1dfb 100644 --- a/src/integration-test/kotlin/sqlite/DocumentIT.kt +++ b/src/integration-test/kotlin/sqlite/DocumentIT.kt @@ -34,4 +34,24 @@ class DocumentIT { @DisplayName("insert succeeds with random string auto ID") fun insertStringAutoId() = SQLiteDB().use(Document::insertStringAutoId) + + @Test + @DisplayName("save updates an existing document") + fun saveMatch() = + SQLiteDB().use(Document::saveMatch) + + @Test + @DisplayName("save inserts a new document") + fun saveNoMatch() = + SQLiteDB().use(Document::saveNoMatch) + + @Test + @DisplayName("update replaces an existing document") + fun updateMatch() = + SQLiteDB().use(Document::updateMatch) + + @Test + @DisplayName("update succeeds when no document exists") + fun updateNoMatch() = + SQLiteDB().use(Document::updateNoMatch) } diff --git a/src/main/kotlin/ConnectionExtensions.kt b/src/main/kotlin/ConnectionExtensions.kt index 9db989c..841fb31 100644 --- a/src/main/kotlin/ConnectionExtensions.kt +++ b/src/main/kotlin/ConnectionExtensions.kt @@ -93,6 +93,25 @@ fun Connection.ensureDocumentIndex(tableName: String, indexType: DocumentIndex) inline fun Connection.insert(tableName: String, document: TDoc) = Document.insert(tableName, document, this) +/** + * Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") + * + * @param tableName The table in which the document should be saved (may include schema) + * @param document The document to be saved + */ +inline fun Connection.save(tableName: String, document: TDoc) = + Document.save(tableName, document, this) + +/** + * Update (replace) a document by its ID + * + * @param tableName The table in which the document should be replaced (may include schema) + * @param docId The ID of the document to be replaced + * @param document The document to be replaced + */ +inline fun Connection.update(tableName: String, docId: TKey, document: TDoc) = + Document.update(tableName, docId, document, this) + // ~~~ DOCUMENT COUNT QUERIES ~~~ /** diff --git a/src/main/kotlin/Document.kt b/src/main/kotlin/Document.kt index 0f21a75..fff4a3a 100644 --- a/src/main/kotlin/Document.kt +++ b/src/main/kotlin/Document.kt @@ -2,6 +2,8 @@ package solutions.bitbadger.documents import java.sql.Connection import solutions.bitbadger.documents.query.Document +import solutions.bitbadger.documents.query.Where +import solutions.bitbadger.documents.query.statementWhere /** * Functions for manipulating documents @@ -20,24 +22,25 @@ object Document { val query = if (strategy == AutoId.DISABLED) { Document.insert(tableName) } else { - val idField = Configuration.idField - val dialect = Configuration.dialect("Create auto-ID insert query") + val idField = Configuration.idField + val dialect = Configuration.dialect("Create auto-ID insert query") val dataParam = if (AutoId.needsAutoId(strategy, document, idField)) { when (dialect) { Dialect.POSTGRESQL -> when (strategy) { - AutoId.NUMBER -> "' || (SELECT coalesce(max(data->>'$idField')::numeric, 0) + 1 FROM $tableName) || '" - AutoId.UUID -> "\"${AutoId.generateUUID()}\"" + AutoId.NUMBER -> "' || (SELECT coalesce(max(data->>'$idField')::numeric, 0) + 1 " + + "FROM $tableName) || '" + AutoId.UUID -> "\"${AutoId.generateUUID()}\"" AutoId.RANDOM_STRING -> "\"${AutoId.generateRandomString()}\"" - else -> "\"' || (:data)->>'$idField' || '\"" + else -> "\"' || (:data)->>'$idField' || '\"" }.let { ":data::jsonb || ('{\"$idField\":$it}')::jsonb" } Dialect.SQLITE -> when (strategy) { - AutoId.NUMBER -> "(SELECT coalesce(max(data->>'$idField'), 0) + 1 FROM $tableName)" - AutoId.UUID -> "'${AutoId.generateUUID()}'" + AutoId.NUMBER -> "(SELECT coalesce(max(data->>'$idField'), 0) + 1 FROM $tableName)" + AutoId.UUID -> "'${AutoId.generateUUID()}'" AutoId.RANDOM_STRING -> "'${AutoId.generateRandomString()}'" - else -> "(:data)->>'$idField'" + else -> "(:data)->>'$idField'" }.let { "json_set(:data, '$.$idField', $it)" } } } else { @@ -57,4 +60,50 @@ object Document { */ inline fun insert(tableName: String, document: TDoc) = Configuration.dbConn().use { insert(tableName, document, it) } + + /** + * Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") + * + * @param tableName The table in which the document should be saved (may include schema) + * @param document The document to be saved + * @param conn The connection on which the query should be executed + */ + inline fun save(tableName: String, document: TDoc, conn: Connection) = + conn.customNonQuery(Document.save(tableName), listOf(Parameters.json(":data", document))) + + /** + * Save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") + * + * @param tableName The table in which the document should be saved (may include schema) + * @param document The document to be saved + */ + inline fun save(tableName: String, document: TDoc) = + Configuration.dbConn().use { save(tableName, document, it) } + + /** + * Update (replace) a document by its ID + * + * @param tableName The table in which the document should be replaced (may include schema) + * @param docId The ID of the document to be replaced + * @param document The document to be replaced + * @param conn The connection on which the query should be executed + */ + inline fun update(tableName: String, docId: TKey, document: TDoc, conn: Connection) = + conn.customNonQuery( + statementWhere(Document.update(tableName), Where.byId(":id", docId)), + Parameters.addFields( + listOf(Field.equal(Configuration.idField, docId, ":id")), + mutableListOf(Parameters.json(":data", document)) + ) + ) + + /** + * Update (replace) a document by its ID + * + * @param tableName The table in which the document should be replaced (may include schema) + * @param docId The ID of the document to be replaced + * @param document The document to be replaced + */ + inline fun update(tableName: String, docId: TKey, document: TDoc) = + Configuration.dbConn().use { update(tableName, docId, document, it) } }