From d0375d14fc93a8590cf2568c155333921457230e Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 1 Mar 2025 18:56:35 -0500 Subject: [PATCH] Add patch functions --- src/integration-test/kotlin/common/Patch.kt | 87 +++++++++++ .../kotlin/postgresql/PatchIT.kt | 52 +++++++ src/integration-test/kotlin/sqlite/PatchIT.kt | 46 ++++++ src/main/kotlin/ConnectionExtensions.kt | 54 +++++++ src/main/kotlin/Patch.kt | 135 ++++++++++++++++++ 5 files changed, 374 insertions(+) create mode 100644 src/integration-test/kotlin/common/Patch.kt create mode 100644 src/integration-test/kotlin/postgresql/PatchIT.kt create mode 100644 src/integration-test/kotlin/sqlite/PatchIT.kt create mode 100644 src/main/kotlin/Patch.kt diff --git a/src/integration-test/kotlin/common/Patch.kt b/src/integration-test/kotlin/common/Patch.kt new file mode 100644 index 0000000..bbacc48 --- /dev/null +++ b/src/integration-test/kotlin/common/Patch.kt @@ -0,0 +1,87 @@ +package solutions.bitbadger.documents.common + +import solutions.bitbadger.documents.* +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Integration tests for the `Find` object + */ +object Patch { + + fun byIdMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + db.conn.patchById(TEST_TABLE, "one", mapOf("numValue" to 44)) + 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(44, doc.numValue, "The document was not patched") + } + + fun byIdNoMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + assertFalse("Document with ID \"forty-seven\" should not exist") { + db.conn.existsById(TEST_TABLE, "forty-seven") + } + db.conn.patchById(TEST_TABLE, "forty-seven", mapOf("foo" to "green")) // no exception = pass + } + + fun byFieldsMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + db.conn.patchByFields(TEST_TABLE, listOf(Field.equal("value", "purple")), mapOf("numValue" to 77)) + assertEquals( + 2, + db.conn.countByFields(TEST_TABLE, listOf(Field.equal("numValue", 77))), + "There should have been 2 documents with numeric value 77" + ) + } + + fun byFieldsNoMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + val fields = listOf(Field.equal("value", "burgundy")) + assertFalse("There should be no documents with value of \"burgundy\"") { + db.conn.existsByFields(TEST_TABLE, fields) + } + db.conn.patchByFields(TEST_TABLE, fields, mapOf("foo" to "green")) // no exception = pass + } + + fun byContainsMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + val contains = mapOf("value" to "another") + db.conn.patchByContains(TEST_TABLE, contains, mapOf("numValue" to 12)) + val doc = db.conn.findFirstByContains>(TEST_TABLE, contains) + assertNotNull(doc, "There should have been a document returned") + assertEquals("two", doc.id, "The incorrect document was returned") + assertEquals(12, doc.numValue, "The document was not updated") + } + + fun byContainsNoMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + val contains = mapOf("value" to "updated") + assertFalse("There should be no matching documents") { db.conn.existsByContains(TEST_TABLE, contains) } + db.conn.patchByContains(TEST_TABLE, contains, mapOf("sub.foo" to "green")) // no exception = pass + } + + fun byJsonPathMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + val path = "$.numValue ? (@ > 10)" + db.conn.patchByJsonPath(TEST_TABLE, path, mapOf("value" to "blue")) + val docs = db.conn.findByJsonPath(TEST_TABLE, path) + assertEquals(2, docs.size, "There should have been two documents returned") + docs.forEach { + assertTrue(listOf("four", "five").contains(it.id), "An incorrect document was returned (${it.id})") + assertEquals("blue", it.value, "The value for ID ${it.id} was incorrect") + } + } + + fun byJsonPathNoMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + val path = "$.numValue ? (@ > 100)" + assertFalse("There should be no documents with numeric values over 100") { + db.conn.existsByJsonPath(TEST_TABLE, path) + } + db.conn.patchByJsonPath(TEST_TABLE, path, mapOf("value" to "blue")) // no exception = pass + } +} diff --git a/src/integration-test/kotlin/postgresql/PatchIT.kt b/src/integration-test/kotlin/postgresql/PatchIT.kt new file mode 100644 index 0000000..225e8f9 --- /dev/null +++ b/src/integration-test/kotlin/postgresql/PatchIT.kt @@ -0,0 +1,52 @@ +package solutions.bitbadger.documents.postgresql + +import org.junit.jupiter.api.DisplayName +import solutions.bitbadger.documents.common.Patch +import kotlin.test.Test + +/** + * PostgreSQL integration tests for the `Patch` object / `patchBy*` connection extension functions + */ +@DisplayName("PostgreSQL - Patch") +class PatchIT { + + @Test + @DisplayName("byId patches an existing document") + fun byIdMatch() = + PgDB().use(Patch::byIdMatch) + + @Test + @DisplayName("byId succeeds for a non-existent document") + fun byIdNoMatch() = + PgDB().use(Patch::byIdNoMatch) + + @Test + @DisplayName("byFields patches matching document") + fun byFieldsMatch() = + PgDB().use(Patch::byFieldsMatch) + + @Test + @DisplayName("byFields succeeds when no documents match") + fun byFieldsNoMatch() = + PgDB().use(Patch::byFieldsNoMatch) + + @Test + @DisplayName("byContains patches matching document") + fun byContainsMatch() = + PgDB().use(Patch::byContainsMatch) + + @Test + @DisplayName("byContains succeeds when no documents match") + fun byContainsNoMatch() = + PgDB().use(Patch::byContainsNoMatch) + + @Test + @DisplayName("byJsonPath patches matching document") + fun byJsonPathMatch() = + PgDB().use(Patch::byJsonPathMatch) + + @Test + @DisplayName("byJsonPath succeeds when no documents match") + fun byJsonPathNoMatch() = + PgDB().use(Patch::byJsonPathNoMatch) +} diff --git a/src/integration-test/kotlin/sqlite/PatchIT.kt b/src/integration-test/kotlin/sqlite/PatchIT.kt new file mode 100644 index 0000000..b6a905f --- /dev/null +++ b/src/integration-test/kotlin/sqlite/PatchIT.kt @@ -0,0 +1,46 @@ +package solutions.bitbadger.documents.sqlite + +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.assertThrows +import solutions.bitbadger.documents.DocumentException +import solutions.bitbadger.documents.common.Patch +import kotlin.test.Test + +/** + * SQLite integration tests for the `Patch` object / `patchBy*` connection extension functions + */ +@DisplayName("SQLite - Patch") +class PatchIT { + + @Test + @DisplayName("byId patches an existing document") + fun byIdMatch() = + SQLiteDB().use(Patch::byIdMatch) + + @Test + @DisplayName("byId succeeds for a non-existent document") + fun byIdNoMatch() = + SQLiteDB().use(Patch::byIdNoMatch) + + @Test + @DisplayName("byFields patches matching document") + fun byFieldsMatch() = + SQLiteDB().use(Patch::byFieldsMatch) + + @Test + @DisplayName("byFields succeeds when no documents match") + fun byFieldsNoMatch() = + SQLiteDB().use(Patch::byFieldsNoMatch) + + @Test + @DisplayName("byContains fails") + fun byContainsFails() { + assertThrows { SQLiteDB().use(Patch::byContainsMatch) } + } + + @Test + @DisplayName("byJsonPath fails") + fun byJsonPathFails() { + assertThrows { SQLiteDB().use(Patch::byJsonPathMatch) } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ConnectionExtensions.kt b/src/main/kotlin/ConnectionExtensions.kt index ec0cbb9..9b7476d 100644 --- a/src/main/kotlin/ConnectionExtensions.kt +++ b/src/main/kotlin/ConnectionExtensions.kt @@ -302,6 +302,60 @@ inline fun Connection.findFirstByJsonPath( ) = Find.firstByJsonPath(tableName, path, orderBy, this) +// ~~~ DOCUMENT PATCH (PARTIAL UPDATE) QUERIES ~~~ + +/** + * Patch a document by its ID + * + * @param tableName The name of the table in which a document should be patched + * @param docId The ID of the document to be patched + * @param patch The object whose properties should be replaced in the document + */ +inline fun Connection.patchById(tableName: String, docId: TKey, patch: TPatch) = + Patch.byId(tableName, docId, patch, this) + +/** + * Patch documents using a field comparison + * + * @param tableName The name of the table in which documents should be patched + * @param fields The fields which should be compared + * @param patch The object whose properties should be replaced in the document + * @param howMatched How the fields should be matched + */ +inline fun Connection.patchByFields( + tableName: String, + fields: Collection>, + patch: TPatch, + howMatched: FieldMatch? = null +) = + Patch.byFields(tableName, fields, patch, howMatched, this) + +/** + * Patch documents using a JSON containment query (PostgreSQL only) + * + * @param tableName The name of the table in which documents should be patched + * @param criteria The object against which JSON containment should be checked + * @param patch The object whose properties should be replaced in the document + * @throws DocumentException If called on a SQLite connection + */ +inline fun Connection.patchByContains( + tableName: String, + criteria: TContains, + patch: TPatch +) = + Patch.byContains(tableName, criteria, patch, this) + +/** + * Patch documents using a JSON Path match query (PostgreSQL only) + * + * @param tableName The name of the table in which documents should be patched + * @param path The JSON path comparison to match + * @param patch The object whose properties should be replaced in the document + * @throws DocumentException If called on a SQLite connection + */ +inline fun Connection.patchByJsonPath(tableName: String, path: String, patch: TPatch) = + Patch.byJsonPath(tableName, path, patch, this) + // ~~~ DOCUMENT DELETION QUERIES ~~~ /** diff --git a/src/main/kotlin/Patch.kt b/src/main/kotlin/Patch.kt new file mode 100644 index 0000000..ea3128c --- /dev/null +++ b/src/main/kotlin/Patch.kt @@ -0,0 +1,135 @@ +package solutions.bitbadger.documents + +import solutions.bitbadger.documents.query.Patch +import java.sql.Connection + +/** + * Functions to patch (partially update) documents + */ +object Patch { + + /** + * Patch a document by its ID + * + * @param tableName The name of the table in which a document should be patched + * @param docId The ID of the document to be patched + * @param patch The object whose properties should be replaced in the document + * @param conn The connection on which the update should be executed + */ + inline fun byId(tableName: String, docId: TKey, patch: TPatch, conn: Connection) = + conn.customNonQuery( + Patch.byId(tableName, docId), + Parameters.addFields( + listOf(Field.equal(Configuration.idField, docId, ":id")), + mutableListOf(Parameters.json(":data", patch)) + ) + ) + + /** + * Patch a document by its ID + * + * @param tableName The name of the table in which a document should be patched + * @param docId The ID of the document to be patched + * @param patch The object whose properties should be replaced in the document + */ + inline fun byId(tableName: String, docId: TKey, patch: TPatch) = + Configuration.dbConn().use { byId(tableName, docId, patch, it) } + + /** + * Patch documents using a field comparison + * + * @param tableName The name of the table in which documents should be patched + * @param fields The fields which should be compared + * @param patch The object whose properties should be replaced in the document + * @param howMatched How the fields should be matched + * @param conn The connection on which the update should be executed + */ + inline fun byFields( + tableName: String, + fields: Collection>, + patch: TPatch, + howMatched: FieldMatch? = null, + conn: Connection + ) { + val named = Parameters.nameFields(fields) + conn.customNonQuery( + Patch.byFields(tableName, named, howMatched), Parameters.addFields( + named, + mutableListOf(Parameters.json(":data", patch)) + ) + ) + } + + /** + * Patch documents using a field comparison + * + * @param tableName The name of the table in which documents should be patched + * @param fields The fields which should be compared + * @param patch The object whose properties should be replaced in the document + * @param howMatched How the fields should be matched + */ + inline fun byFields( + tableName: String, + fields: Collection>, + patch: TPatch, + howMatched: FieldMatch? = null + ) = + Configuration.dbConn().use { byFields(tableName, fields, patch, howMatched, it) } + + /** + * Patch documents using a JSON containment query (PostgreSQL only) + * + * @param tableName The name of the table in which documents should be patched + * @param criteria The object against which JSON containment should be checked + * @param patch The object whose properties should be replaced in the document + * @param conn The connection on which the update should be executed + * @throws DocumentException If called on a SQLite connection + */ + inline fun byContains( + tableName: String, + criteria: TContains, + patch: TPatch, + conn: Connection + ) = + conn.customNonQuery( + Patch.byContains(tableName), + listOf(Parameters.json(":criteria", criteria), Parameters.json(":data", patch)) + ) + + /** + * Patch documents using a JSON containment query (PostgreSQL only) + * + * @param tableName The name of the table in which documents should be patched + * @param criteria The object against which JSON containment should be checked + * @param patch The object whose properties should be replaced in the document + * @throws DocumentException If called on a SQLite connection + */ + inline fun byContains(tableName: String, criteria: TContains, patch: TPatch) = + Configuration.dbConn().use { byContains(tableName, criteria, patch, it) } + + /** + * Patch documents using a JSON Path match query (PostgreSQL only) + * + * @param tableName The name of the table in which documents should be patched + * @param path The JSON path comparison to match + * @param patch The object whose properties should be replaced in the document + * @param conn The connection on which the update should be executed + * @throws DocumentException If called on a SQLite connection + */ + inline fun byJsonPath(tableName: String, path: String, patch: TPatch, conn: Connection) = + conn.customNonQuery( + Patch.byJsonPath(tableName), + listOf(Parameter(":path", ParameterType.STRING, path), Parameters.json(":data", patch)) + ) + + /** + * Patch documents using a JSON Path match query (PostgreSQL only) + * + * @param tableName The name of the table in which documents should be patched + * @param path The JSON path comparison to match + * @param patch The object whose properties should be replaced in the document + * @throws DocumentException If called on a SQLite connection + */ + inline fun byJsonPath(tableName: String, path: String, patch: TPatch) = + Configuration.dbConn().use { byJsonPath(tableName, path, patch, it) } +}