From 18ba1be191c72d2f5de835952d188e3da1af7785 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 27 Mar 2025 20:26:51 -0400 Subject: [PATCH] Add JSON string custom functions --- src/core/src/main/kotlin/java/Custom.kt | 68 ++++++++++++ src/core/src/main/kotlin/java/Results.kt | 44 ++++++++ .../main/kotlin/java/extensions/Connection.kt | 34 ++++++ .../java/integration/CustomFunctions.java | 36 +++++++ .../tests/java/integration/JsonFunctions.java | 22 ++++ .../java/integration/PostgreSQLCustomIT.java | 40 +++++++ .../java/integration/SQLiteCustomIT.java | 40 +++++++ .../kotlin/integration/CustomFunctions.kt | 44 ++++++++ .../test/kotlin/integration/JsonFunctions.kt | 21 ++++ .../kotlin/integration/PostgreSQLCustomIT.kt | 25 +++++ .../test/kotlin/integration/SQLiteCustomIT.kt | 25 +++++ .../tests/integration/CustomFunctions.groovy | 35 ++++++ .../tests/integration/JsonFunctions.groovy | 20 ++++ .../integration/PostgreSQLCustomIT.groovy | 30 ++++++ src/kotlinx/src/main/kotlin/Custom.kt | 62 ++++++++++- src/kotlinx/src/main/kotlin/Results.kt | 31 ++++++ .../src/main/kotlin/extensions/Connection.kt | 48 ++++++--- .../kotlin/integration/CustomFunctions.kt | 44 ++++++++ .../test/kotlin/integration/JsonFunctions.kt | 20 ++++ .../kotlin/integration/PostgreSQLCustomIT.kt | 25 +++++ .../test/kotlin/integration/SQLiteCustomIT.kt | 25 +++++ src/scala/src/main/scala/Custom.scala | 101 +++++++++++++++++- src/scala/src/main/scala/Results.scala | 41 +++++++ .../src/main/scala/extensions/package.scala | 46 ++++++++ .../scala/integration/CustomFunctions.scala | 34 +++++- .../scala/integration/JsonFunctions.scala | 17 +++ .../integration/PostgreSQLCustomIT.scala | 25 +++++ .../scala/integration/SQLiteCustomIT.scala | 25 +++++ 28 files changed, 1012 insertions(+), 16 deletions(-) create mode 100644 src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/JsonFunctions.java create mode 100644 src/core/src/test/kotlin/integration/JsonFunctions.kt create mode 100644 src/groovy/src/test/groovy/solutions/bitbadger/documents/groovy/tests/integration/JsonFunctions.groovy create mode 100644 src/kotlinx/src/test/kotlin/integration/JsonFunctions.kt create mode 100644 src/scala/src/test/scala/integration/JsonFunctions.scala diff --git a/src/core/src/main/kotlin/java/Custom.kt b/src/core/src/main/kotlin/java/Custom.kt index b90d963..b1fc6be 100644 --- a/src/core/src/main/kotlin/java/Custom.kt +++ b/src/core/src/main/kotlin/java/Custom.kt @@ -54,6 +54,39 @@ object Custom { mapFunc: (ResultSet, Class) -> TDoc ) = Configuration.dbConn().use { list(query, parameters, clazz, it, mapFunc) } + /** + * Execute a query that returns a JSON array of results + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param conn The connection over which the query should be executed + * @param mapFunc The mapping function to extract the JSON from the query + * @return A JSON array of results for the given query + * @throws DocumentException If parameters are invalid + */ + @Throws(DocumentException::class) + @JvmStatic + fun jsonArray( + query: String, + parameters: Collection> = listOf(), + conn: Connection, + mapFunc: (ResultSet) -> String + ) = Parameters.apply(conn, query, parameters).use { Results.toJsonArray(it, mapFunc) } + + /** + * Execute a query that returns a JSON array of results (creates connection) + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param mapFunc The mapping function to extract the JSON from the query + * @return A JSON array of results for the given query + * @throws DocumentException If parameters are invalid + */ + @Throws(DocumentException::class) + @JvmStatic + fun jsonArray(query: String, parameters: Collection> = listOf(), mapFunc: (ResultSet) -> String) = + Configuration.dbConn().use { jsonArray(query, parameters, it, mapFunc) } + /** * Execute a query that returns one or no results * @@ -94,6 +127,41 @@ object Custom { mapFunc: (ResultSet, Class) -> TDoc ) = Configuration.dbConn().use { single(query, parameters, clazz, it, mapFunc) } + /** + * Execute a query that returns JSON for one or no documents + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param conn The connection over which the query should be executed + * @param mapFunc The mapping function between the document and the domain item + * @return The JSON for the document if found, an empty object (`{}`) if not + * @throws DocumentException If parameters are invalid + */ + @Throws(DocumentException::class) + @JvmStatic + fun jsonSingle( + query: String, + parameters: Collection> = listOf(), + conn: Connection, + mapFunc: (ResultSet) -> String + ) = jsonArray("$query LIMIT 1", parameters, conn, mapFunc).let { + if (it == "[]") "{}" else it.substring(1, it.length - 1) + } + + /** + * Execute a query that returns JSON for one or no documents (creates connection) + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param mapFunc The mapping function between the document and the domain item + * @return The JSON for the document if found, an empty object (`{}`) if not + * @throws DocumentException If parameters are invalid + */ + @Throws(DocumentException::class) + @JvmStatic + fun jsonSingle(query: String, parameters: Collection> = listOf(), mapFunc: (ResultSet) -> String) = + Configuration.dbConn().use { jsonSingle(query, parameters, it, mapFunc) } + /** * Execute a query that returns no results * diff --git a/src/core/src/main/kotlin/java/Results.kt b/src/core/src/main/kotlin/java/Results.kt index 553329a..66fc15c 100644 --- a/src/core/src/main/kotlin/java/Results.kt +++ b/src/core/src/main/kotlin/java/Results.kt @@ -89,4 +89,48 @@ object Results { Dialect.POSTGRESQL -> rs.getBoolean("it") Dialect.SQLITE -> toCount(rs, Long::class.java) > 0L } + + /** + * Retrieve the JSON text of a document, specifying the field in which the document is found + * + * @param field The field name containing the JSON document + * @param rs A `ResultSet` set to the row with the document to be constructed + * @return The JSON text of the document + */ + @JvmStatic + fun jsonFromDocument(field: String, rs: ResultSet) = + rs.getString(field) + + /** + * Retrieve the JSON text of a document, specifying the field in which the document is found + * + * @param rs A `ResultSet` set to the row with the document to be constructed + * @return The JSON text of the document + */ + @JvmStatic + fun jsonFromData(rs: ResultSet) = + jsonFromDocument("data", rs) + + /** + * Create a JSON array of items for the results of the given command, using the specified mapping function + * + * @param stmt The prepared statement to execute + * @param mapFunc The mapping function from data reader to JSON text + * @return A string with a JSON array of documents from the query's result + * @throws DocumentException If there is a problem executing the query (unchecked) + */ + @JvmStatic + fun toJsonArray(stmt: PreparedStatement, mapFunc: (ResultSet) -> String) = + try { + val results = StringBuilder("[") + stmt.executeQuery().use { + while (it.next()) { + if (results.length > 2) results.append(",") + results.append(mapFunc(it)) + } + } + results.append("]").toString() + } catch (ex: SQLException) { + throw DocumentException("Error retrieving documents from query: ${ex.message}", ex) + } } diff --git a/src/core/src/main/kotlin/java/extensions/Connection.kt b/src/core/src/main/kotlin/java/extensions/Connection.kt index f28a103..b23ef02 100644 --- a/src/core/src/main/kotlin/java/extensions/Connection.kt +++ b/src/core/src/main/kotlin/java/extensions/Connection.kt @@ -29,6 +29,23 @@ fun Connection.customList( ) = Custom.list(query, parameters, clazz, this, mapFunc) +/** + * Execute a query that returns a JSON array of results + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param mapFunc The mapping function to extract the JSON from the query + * @return A JSON array of results for the given query + * @throws DocumentException If parameters are invalid + */ +@Throws(DocumentException::class) +fun Connection.customJsonArray( + query: String, + parameters: Collection> = listOf(), + mapFunc: (ResultSet) -> String +) = + Custom.jsonArray(query, parameters, this, mapFunc) + /** * Execute a query that returns one or no results * @@ -48,6 +65,23 @@ fun Connection.customSingle( ) = Custom.single(query, parameters, clazz, this, mapFunc) +/** + * Execute a query that returns JSON for one or no documents + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param mapFunc The mapping function between the document and the domain item + * @return The JSON for the document if found, an empty object (`{}`) if not + * @throws DocumentException If parameters are invalid + */ +@Throws(DocumentException::class) +fun Connection.customJsonSingle( + query: String, + parameters: Collection> = listOf(), + mapFunc: (ResultSet) -> String +) = + Configuration.dbConn().use { Custom.jsonSingle(query, parameters, it, mapFunc) } + /** * Execute a query that returns no results * diff --git a/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/CustomFunctions.java b/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/CustomFunctions.java index 7c6ee1b..a2a4d55 100644 --- a/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/CustomFunctions.java +++ b/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/CustomFunctions.java @@ -13,6 +13,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.*; import static solutions.bitbadger.documents.core.tests.TypesKt.TEST_TABLE; import static solutions.bitbadger.documents.java.extensions.ConnExt.*; +import static solutions.bitbadger.documents.query.QueryUtils.orderBy; final public class CustomFunctions { @@ -31,6 +32,29 @@ final public class CustomFunctions { assertEquals(5, result.size(), "There should have been 5 results"); } + public static void jsonArrayEmpty(ThrowawayDatabase db) throws DocumentException { + assertEquals(0L, countAll(db.getConn(), TEST_TABLE), "The test table should be empty"); + assertEquals("[]", customJsonArray(db.getConn(), FindQuery.all(TEST_TABLE), List.of(), Results::jsonFromData), + "An empty list was not represented correctly"); + } + + public static void jsonArraySingle(ThrowawayDatabase db) throws DocumentException { + insert(db.getConn(), TEST_TABLE, new ArrayDocument("one", List.of("2", "3"))); + assertEquals(JsonFunctions.maybeJsonB("[{\"id\":\"one\",\"values\":[\"2\",\"3\"]}]"), + customJsonArray(db.getConn(), FindQuery.all(TEST_TABLE), List.of(), Results::jsonFromData), + "A single document list was not represented correctly"); + } + + public static void jsonArrayMany(ThrowawayDatabase db) throws DocumentException { + for (ArrayDocument doc : ArrayDocument.testDocuments) { insert(db.getConn(), TEST_TABLE, doc); } + assertEquals(JsonFunctions.maybeJsonB("[{\"id\":\"first\",\"values\":[\"a\",\"b\",\"c\"]}," + + "{\"id\":\"second\",\"values\":[\"c\",\"d\",\"e\"]}," + + "{\"id\":\"third\",\"values\":[\"x\",\"y\",\"z\"]}]"), + customJsonArray(db.getConn(), FindQuery.all(TEST_TABLE) + orderBy(List.of(Field.named("id"))), + List.of(), Results::jsonFromData), + "A multiple document list was not represented correctly"); + } + public static void singleNone(ThrowawayDatabase db) throws DocumentException { assertFalse( customSingle(db.getConn(), FindQuery.all(TEST_TABLE), List.of(), JsonDocument.class, Results::fromData) @@ -46,6 +70,18 @@ final public class CustomFunctions { "There should have been a document returned"); } + public static void jsonSingleNone(ThrowawayDatabase db) throws DocumentException { + assertEquals("{}", customJsonSingle(db.getConn(), FindQuery.all(TEST_TABLE), List.of(), Results::jsonFromData), + "An empty document was not represented correctly"); + } + + public static void jsonSingleOne(ThrowawayDatabase db) throws DocumentException { + insert(db.getConn(), TEST_TABLE, new ArrayDocument("me", List.of("myself", "i"))); + assertEquals(JsonFunctions.maybeJsonB("{\"id\":\"me\",\"values\":[\"myself\",\"i\"]}"), + customJsonSingle(db.getConn(), FindQuery.all(TEST_TABLE), List.of(), Results::jsonFromData), + "A single document was not represented correctly"); + } + public static void nonQueryChanges(ThrowawayDatabase db) throws DocumentException { JsonDocument.load(db); assertEquals(5L, diff --git a/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/JsonFunctions.java b/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/JsonFunctions.java new file mode 100644 index 0000000..0d440d2 --- /dev/null +++ b/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/JsonFunctions.java @@ -0,0 +1,22 @@ +package solutions.bitbadger.documents.core.tests.java.integration; + +import solutions.bitbadger.documents.Configuration; +import solutions.bitbadger.documents.Dialect; +import solutions.bitbadger.documents.DocumentException; + +public class JsonFunctions { + + /** + * PostgreSQL, when returning JSONB as a string, has spaces after commas and colons delineating fields and values. + * This function will do a crude string replacement to match the target string based on the dialect being tested. + * + * @param json The JSON which should be returned + * @return The actual expected JSON based on the database being tested + */ + public static String maybeJsonB(String json) throws DocumentException { + if (Configuration.dialect() == Dialect.SQLITE) { + return json; + } + return json.replace("\":\"", "\": \"").replace("\",\"", "\", \"").replace("\":[", "\": ["); + } +} diff --git a/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/PostgreSQLCustomIT.java b/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/PostgreSQLCustomIT.java index d2c2b01..a2d8f5c 100644 --- a/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/PostgreSQLCustomIT.java +++ b/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/PostgreSQLCustomIT.java @@ -27,6 +27,30 @@ final public class PostgreSQLCustomIT { } } + @Test + @DisplayName("jsonArray succeeds with empty array") + public void jsonArrayEmpty() throws DocumentException { + try (PgDB db = new PgDB()) { + CustomFunctions.jsonArrayEmpty(db); + } + } + + @Test + @DisplayName("jsonArray succeeds with a single-item array") + public void jsonArraySingle() throws DocumentException { + try (PgDB db = new PgDB()) { + CustomFunctions.jsonArraySingle(db); + } + } + + @Test + @DisplayName("jsonArray succeeds with a multi-item array") + public void jsonArrayMany() throws DocumentException { + try (PgDB db = new PgDB()) { + CustomFunctions.jsonArrayMany(db); + } + } + @Test @DisplayName("single succeeds when document not found") public void singleNone() throws DocumentException { @@ -43,6 +67,22 @@ final public class PostgreSQLCustomIT { } } + @Test + @DisplayName("jsonSingle succeeds when document not found") + public void jsonSingleNone() throws DocumentException { + try (PgDB db = new PgDB()) { + CustomFunctions.jsonSingleNone(db); + } + } + + @Test + @DisplayName("jsonSingle succeeds when a document is found") + public void jsonSingleOne() throws DocumentException { + try (PgDB db = new PgDB()) { + CustomFunctions.jsonSingleOne(db); + } + } + @Test @DisplayName("nonQuery makes changes") public void nonQueryChanges() throws DocumentException { diff --git a/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/SQLiteCustomIT.java b/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/SQLiteCustomIT.java index 6398554..66bc246 100644 --- a/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/SQLiteCustomIT.java +++ b/src/core/src/test/java/solutions/bitbadger/documents/core/tests/java/integration/SQLiteCustomIT.java @@ -27,6 +27,30 @@ final public class SQLiteCustomIT { } } + @Test + @DisplayName("jsonArray succeeds with empty array") + public void jsonArrayEmpty() throws DocumentException { + try (SQLiteDB db = new SQLiteDB()) { + CustomFunctions.jsonArrayEmpty(db); + } + } + + @Test + @DisplayName("jsonArray succeeds with a single-item array") + public void jsonArraySingle() throws DocumentException { + try (SQLiteDB db = new SQLiteDB()) { + CustomFunctions.jsonArraySingle(db); + } + } + + @Test + @DisplayName("jsonArray succeeds with a multi-item array") + public void jsonArrayMany() throws DocumentException { + try (SQLiteDB db = new SQLiteDB()) { + CustomFunctions.jsonArrayMany(db); + } + } + @Test @DisplayName("single succeeds when document not found") public void singleNone() throws DocumentException { @@ -43,6 +67,22 @@ final public class SQLiteCustomIT { } } + @Test + @DisplayName("jsonSingle succeeds when document not found") + public void jsonSingleNone() throws DocumentException { + try (SQLiteDB db = new SQLiteDB()) { + CustomFunctions.jsonSingleNone(db); + } + } + + @Test + @DisplayName("jsonSingle succeeds when a document is found") + public void jsonSingleOne() throws DocumentException { + try (SQLiteDB db = new SQLiteDB()) { + CustomFunctions.jsonSingleOne(db); + } + } + @Test @DisplayName("nonQuery makes changes") public void nonQueryChanges() throws DocumentException { diff --git a/src/core/src/test/kotlin/integration/CustomFunctions.kt b/src/core/src/test/kotlin/integration/CustomFunctions.kt index e25fff4..dafce1c 100644 --- a/src/core/src/test/kotlin/integration/CustomFunctions.kt +++ b/src/core/src/test/kotlin/integration/CustomFunctions.kt @@ -7,6 +7,7 @@ import solutions.bitbadger.documents.java.Results import solutions.bitbadger.documents.query.CountQuery import solutions.bitbadger.documents.query.DeleteQuery import solutions.bitbadger.documents.query.FindQuery +import solutions.bitbadger.documents.query.orderBy import kotlin.test.* /** @@ -29,6 +30,36 @@ object CustomFunctions { assertEquals(5, result.size, "There should have been 5 results") } + fun jsonArrayEmpty(db: ThrowawayDatabase) { + assertEquals(0L, db.conn.countAll(TEST_TABLE), "The test table should be empty") + assertEquals( + "[]", + db.conn.customJsonArray(FindQuery.all(TEST_TABLE), listOf(), Results::jsonFromData), + "An empty list was not represented correctly" + ) + } + + fun jsonArraySingle(db: ThrowawayDatabase) { + db.conn.insert(TEST_TABLE, ArrayDocument("one", listOf("2", "3"))) + assertEquals( + JsonFunctions.maybeJsonB("[{\"id\":\"one\",\"values\":[\"2\",\"3\"]}]"), + db.conn.customJsonArray(FindQuery.all(TEST_TABLE), listOf(), Results::jsonFromData), + "A single document list was not represented correctly" + ) + } + + fun jsonArrayMany(db: ThrowawayDatabase) { + ArrayDocument.testDocuments.forEach { db.conn.insert(TEST_TABLE, it) } + assertEquals( + JsonFunctions.maybeJsonB("[{\"id\":\"first\",\"values\":[\"a\",\"b\",\"c\"]}," + + "{\"id\":\"second\",\"values\":[\"c\",\"d\",\"e\"]}," + + "{\"id\":\"third\",\"values\":[\"x\",\"y\",\"z\"]}]"), + db.conn.customJsonArray(FindQuery.all(TEST_TABLE) + orderBy(listOf(Field.named("id"))), listOf(), + Results::jsonFromData), + "A multiple document list was not represented correctly" + ) + } + fun singleNone(db: ThrowawayDatabase) = assertFalse( db.conn.customSingle( @@ -53,6 +84,19 @@ object CustomFunctions { ) } + fun jsonSingleNone(db: ThrowawayDatabase) = + assertEquals("{}", db.conn.customJsonSingle(FindQuery.all(TEST_TABLE), listOf(), Results::jsonFromData), + "An empty document was not represented correctly") + + fun jsonSingleOne(db: ThrowawayDatabase) { + db.conn.insert(TEST_TABLE, ArrayDocument("me", listOf("myself", "i"))) + assertEquals( + JsonFunctions.maybeJsonB("{\"id\":\"me\",\"values\":[\"myself\",\"i\"]}"), + db.conn.customJsonSingle(FindQuery.all(TEST_TABLE), listOf(), Results::jsonFromData), + "A single document was not represented correctly" + ) + } + fun nonQueryChanges(db: ThrowawayDatabase) { JsonDocument.load(db) assertEquals( diff --git a/src/core/src/test/kotlin/integration/JsonFunctions.kt b/src/core/src/test/kotlin/integration/JsonFunctions.kt new file mode 100644 index 0000000..4fe6bea --- /dev/null +++ b/src/core/src/test/kotlin/integration/JsonFunctions.kt @@ -0,0 +1,21 @@ +package solutions.bitbadger.documents.core.tests.integration + +import solutions.bitbadger.documents.Configuration +import solutions.bitbadger.documents.Dialect + +object JsonFunctions { + + /** + * PostgreSQL, when returning JSONB as a string, has spaces after commas and colons delineating fields and values. + * This function will do a crude string replacement to match the target string based on the dialect being tested. + * + * @param json The JSON which should be returned + * @return The actual expected JSON based on the database being tested + */ + fun maybeJsonB(json: String) = + when (Configuration.dialect()) { + Dialect.SQLITE -> json + Dialect.POSTGRESQL -> json.replace("\":\"", "\": \"").replace("\",\"", "\", \"").replace("\":[", "\": [") + } + +} \ No newline at end of file diff --git a/src/core/src/test/kotlin/integration/PostgreSQLCustomIT.kt b/src/core/src/test/kotlin/integration/PostgreSQLCustomIT.kt index 8e943db..ca28dd7 100644 --- a/src/core/src/test/kotlin/integration/PostgreSQLCustomIT.kt +++ b/src/core/src/test/kotlin/integration/PostgreSQLCustomIT.kt @@ -20,6 +20,21 @@ class PostgreSQLCustomIT { fun listAll() = PgDB().use(CustomFunctions::listAll) + @Test + @DisplayName("jsonArray succeeds with empty array") + fun jsonArrayEmpty() = + PgDB().use(CustomFunctions::jsonArrayEmpty) + + @Test + @DisplayName("jsonArray succeeds with a single-item list") + fun jsonArraySingle() = + PgDB().use(CustomFunctions::jsonArraySingle) + + @Test + @DisplayName("jsonArray succeeds with a multi-item list") + fun jsonArrayMany() = + PgDB().use(CustomFunctions::jsonArrayMany) + @Test @DisplayName("single succeeds when document not found") fun singleNone() = @@ -30,6 +45,16 @@ class PostgreSQLCustomIT { fun singleOne() = PgDB().use(CustomFunctions::singleOne) + @Test + @DisplayName("jsonSingle succeeds when document not found") + fun jsonSingleNone() = + PgDB().use(CustomFunctions::jsonSingleNone) + + @Test + @DisplayName("jsonSingle succeeds when a document is found") + fun jsonSingleOne() = + PgDB().use(CustomFunctions::jsonSingleOne) + @Test @DisplayName("nonQuery makes changes") fun nonQueryChanges() = diff --git a/src/core/src/test/kotlin/integration/SQLiteCustomIT.kt b/src/core/src/test/kotlin/integration/SQLiteCustomIT.kt index 470b040..9eca13a 100644 --- a/src/core/src/test/kotlin/integration/SQLiteCustomIT.kt +++ b/src/core/src/test/kotlin/integration/SQLiteCustomIT.kt @@ -19,6 +19,21 @@ class SQLiteCustomIT { fun listAll() = SQLiteDB().use(CustomFunctions::listAll) + @Test + @DisplayName("jsonArray succeeds with empty array") + fun jsonArrayEmpty() = + SQLiteDB().use(CustomFunctions::jsonArrayEmpty) + + @Test + @DisplayName("jsonArray succeeds with a single-item list") + fun jsonArraySingle() = + SQLiteDB().use(CustomFunctions::jsonArraySingle) + + @Test + @DisplayName("jsonArray succeeds with a multi-item list") + fun jsonArrayMany() = + SQLiteDB().use(CustomFunctions::jsonArrayMany) + @Test @DisplayName("single succeeds when document not found") fun singleNone() = @@ -29,6 +44,16 @@ class SQLiteCustomIT { fun singleOne() = SQLiteDB().use(CustomFunctions::singleOne) + @Test + @DisplayName("jsonSingle succeeds when document not found") + fun jsonSingleNone() = + SQLiteDB().use(CustomFunctions::jsonSingleNone) + + @Test + @DisplayName("jsonSingle succeeds when a document is found") + fun jsonSingleOne() = + SQLiteDB().use(CustomFunctions::jsonSingleOne) + @Test @DisplayName("nonQuery makes changes") fun nonQueryChanges() = diff --git a/src/groovy/src/test/groovy/solutions/bitbadger/documents/groovy/tests/integration/CustomFunctions.groovy b/src/groovy/src/test/groovy/solutions/bitbadger/documents/groovy/tests/integration/CustomFunctions.groovy index 72208e0..36a5bc9 100644 --- a/src/groovy/src/test/groovy/solutions/bitbadger/documents/groovy/tests/integration/CustomFunctions.groovy +++ b/src/groovy/src/test/groovy/solutions/bitbadger/documents/groovy/tests/integration/CustomFunctions.groovy @@ -8,6 +8,7 @@ import solutions.bitbadger.documents.java.Results import solutions.bitbadger.documents.query.CountQuery import solutions.bitbadger.documents.query.DeleteQuery import solutions.bitbadger.documents.query.FindQuery +import solutions.bitbadger.documents.query.QueryUtils import static org.junit.jupiter.api.Assertions.* import static solutions.bitbadger.documents.groovy.tests.Types.TEST_TABLE @@ -27,6 +28,28 @@ final class CustomFunctions { assertEquals 5, result.size(), 'There should have been 5 results' } + static void jsonArrayEmpty(ThrowawayDatabase db) { + assertEquals(0L, db.conn.countAll(TEST_TABLE), 'The test table should be empty') + assertEquals('[]', db.conn.customJsonArray(FindQuery.all(TEST_TABLE), List.of(), Results::jsonFromData), + 'An empty list was not represented correctly'); + } + + static void jsonArraySingle(ThrowawayDatabase db) { + db.conn.insert(TEST_TABLE, new ArrayDocument("one", List.of("2", "3"))) + assertEquals(JsonFunctions.maybeJsonB('[{"id":"one","values":["2","3"]}]'), + db.conn.customJsonArray(FindQuery.all(TEST_TABLE), List.of(), Results::jsonFromData), + 'A single document list was not represented correctly') + } + + static void jsonArrayMany(ThrowawayDatabase db) { + ArrayDocument.testDocuments.forEach { db.conn.insert(TEST_TABLE, it) } + assertEquals(JsonFunctions.maybeJsonB('[{"id":"first","values":["a","b","c"]},' + + '{"id":"second","values":["c","d","e"]},{"id":"third","values":["x","y","z"]}]'), + db.conn.customJsonArray(FindQuery.all(TEST_TABLE) + QueryUtils.orderBy(List.of(Field.named("id"))), + List.of(), Results::jsonFromData), + 'A multiple document list was not represented correctly') + } + static void singleNone(ThrowawayDatabase db) { assertFalse(db.conn.customSingle(FindQuery.all(TEST_TABLE), List.of(), JsonDocument, Results.&fromData) .isPresent(), @@ -41,6 +64,18 @@ final class CustomFunctions { 'There should not have been a document returned') } + static void jsonSingleNone(ThrowawayDatabase db) { + assertEquals('{}', db.conn.customJsonSingle(FindQuery.all(TEST_TABLE), List.of(), Results::jsonFromData), + 'An empty document was not represented correctly') + } + + static void jsonSingleOne(ThrowawayDatabase db) { + db.conn.insert(TEST_TABLE, new ArrayDocument("me", List.of("myself", "i"))) + assertEquals(JsonFunctions.maybeJsonB('{"id":"me","values":["myself","i"]}'), + db.conn.customJsonSingle(FindQuery.all(TEST_TABLE), List.of(), Results::jsonFromData), + 'A single document was not represented correctly'); + } + static void nonQueryChanges(ThrowawayDatabase db) { JsonDocument.load db assertEquals(5L, db.conn.customScalar(CountQuery.all(TEST_TABLE), List.of(), Long, Results.&toCount), diff --git a/src/groovy/src/test/groovy/solutions/bitbadger/documents/groovy/tests/integration/JsonFunctions.groovy b/src/groovy/src/test/groovy/solutions/bitbadger/documents/groovy/tests/integration/JsonFunctions.groovy new file mode 100644 index 0000000..f657d5e --- /dev/null +++ b/src/groovy/src/test/groovy/solutions/bitbadger/documents/groovy/tests/integration/JsonFunctions.groovy @@ -0,0 +1,20 @@ +package solutions.bitbadger.documents.groovy.tests.integration + +import solutions.bitbadger.documents.Configuration +import solutions.bitbadger.documents.Dialect + +final class JsonFunctions { + + /** + * PostgreSQL, when returning JSONB as a string, has spaces after commas and colons delineating fields and values. + * This function will do a crude string replacement to match the target string based on the dialect being tested. + * + * @param json The JSON which should be returned + * @return The actual expected JSON based on the database being tested + */ + static String maybeJsonB(String json) { + Configuration.dialect() == Dialect.SQLITE + ? json + : json.replace('":"', '": "').replace('","', '", "').replace('":[', '": [') + } +} diff --git a/src/groovy/src/test/groovy/solutions/bitbadger/documents/groovy/tests/integration/PostgreSQLCustomIT.groovy b/src/groovy/src/test/groovy/solutions/bitbadger/documents/groovy/tests/integration/PostgreSQLCustomIT.groovy index cd7570d..e052725 100644 --- a/src/groovy/src/test/groovy/solutions/bitbadger/documents/groovy/tests/integration/PostgreSQLCustomIT.groovy +++ b/src/groovy/src/test/groovy/solutions/bitbadger/documents/groovy/tests/integration/PostgreSQLCustomIT.groovy @@ -21,6 +21,24 @@ final class PostgreSQLCustomIT { new PgDB().withCloseable CustomFunctions.&listAll } + @Test + @DisplayName('jsonArray succeeds with empty array') + void jsonArrayEmpty() { + new PgDB().withCloseable CustomFunctions.&jsonArrayEmpty + } + + @Test + @DisplayName('jsonArray succeeds with a single-item array') + void jsonArraySingle() { + new PgDB().withCloseable CustomFunctions.&jsonArraySingle + } + + @Test + @DisplayName('jsonArray succeeds with a multi-item array') + void jsonArrayMany() { + new PgDB().withCloseable CustomFunctions.&jsonArrayMany + } + @Test @DisplayName('single succeeds when document not found') void singleNone() { @@ -33,6 +51,18 @@ final class PostgreSQLCustomIT { new PgDB().withCloseable CustomFunctions.&singleOne } + @Test + @DisplayName('jsonSingle succeeds when document not found') + void jsonSingleNone() { + new PgDB().withCloseable CustomFunctions.&jsonSingleNone + } + + @Test + @DisplayName('jsonSingle succeeds when a document is found') + void jsonSingleOne() { + new PgDB().withCloseable CustomFunctions.&jsonSingleOne + } + @Test @DisplayName('nonQuery makes changes') void nonQueryChanges() { diff --git a/src/kotlinx/src/main/kotlin/Custom.kt b/src/kotlinx/src/main/kotlin/Custom.kt index c5d5e7e..3b9a4be 100644 --- a/src/kotlinx/src/main/kotlin/Custom.kt +++ b/src/kotlinx/src/main/kotlin/Custom.kt @@ -2,7 +2,7 @@ package solutions.bitbadger.documents.kotlinx import solutions.bitbadger.documents.* import solutions.bitbadger.documents.Configuration -import solutions.bitbadger.documents.java.Custom as JvmCustom +import solutions.bitbadger.documents.java.Custom as CoreCustom import java.sql.Connection import java.sql.ResultSet @@ -41,6 +41,35 @@ object Custom { mapFunc: (ResultSet) -> TDoc ) = Configuration.dbConn().use { list(query, parameters, it, mapFunc) } + /** + * Execute a query that returns a JSON array of results + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param conn The connection over which the query should be executed + * @param mapFunc The mapping function to extract the JSON from the query + * @return A JSON array of results for the given query + * @throws DocumentException If parameters are invalid + */ + fun jsonArray( + query: String, + parameters: Collection> = listOf(), + conn: Connection, + mapFunc: (ResultSet) -> String + ) = CoreCustom.jsonArray(query, parameters, conn, mapFunc) + + /** + * Execute a query that returns a JSON array of results (creates connection) + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param mapFunc The mapping function to extract the JSON from the query + * @return A JSON array of results for the given query + * @throws DocumentException If parameters are invalid + */ + fun jsonArray(query: String, parameters: Collection> = listOf(), mapFunc: (ResultSet) -> String) = + CoreCustom.jsonArray(query, parameters, mapFunc) + /** * Execute a query that returns one or no results * @@ -71,6 +100,35 @@ object Custom { noinline mapFunc: (ResultSet) -> TDoc ) = Configuration.dbConn().use { single(query, parameters, it, mapFunc) } + /** + * Execute a query that returns JSON for one or no documents + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param conn The connection over which the query should be executed + * @param mapFunc The mapping function between the document and the domain item + * @return The JSON for the document if found, an empty object (`{}`) if not + * @throws DocumentException If parameters are invalid + */ + fun jsonSingle( + query: String, + parameters: Collection> = listOf(), + conn: Connection, + mapFunc: (ResultSet) -> String + ) = CoreCustom.jsonSingle(query, parameters, conn, mapFunc) + + /** + * Execute a query that returns JSON for one or no documents (creates connection) + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param mapFunc The mapping function between the document and the domain item + * @return The JSON for the document if found, an empty object (`{}`) if not + * @throws DocumentException If parameters are invalid + */ + fun jsonSingle(query: String, parameters: Collection> = listOf(), mapFunc: (ResultSet) -> String) = + CoreCustom.jsonSingle(query, parameters, mapFunc) + /** * Execute a query that returns no results * @@ -79,7 +137,7 @@ object Custom { * @param parameters Parameters to use for the query */ fun nonQuery(query: String, parameters: Collection> = listOf(), conn: Connection) = - JvmCustom.nonQuery(query, parameters, conn) + CoreCustom.nonQuery(query, parameters, conn) /** * Execute a query that returns no results diff --git a/src/kotlinx/src/main/kotlin/Results.kt b/src/kotlinx/src/main/kotlin/Results.kt index d63a363..e4b04ae 100644 --- a/src/kotlinx/src/main/kotlin/Results.kt +++ b/src/kotlinx/src/main/kotlin/Results.kt @@ -3,6 +3,7 @@ package solutions.bitbadger.documents.kotlinx import solutions.bitbadger.documents.Configuration import solutions.bitbadger.documents.Dialect import solutions.bitbadger.documents.DocumentException +import solutions.bitbadger.documents.java.Results as CoreResults import java.sql.PreparedStatement import java.sql.ResultSet import java.sql.SQLException @@ -73,4 +74,34 @@ object Results { Dialect.POSTGRESQL -> rs.getBoolean("it") Dialect.SQLITE -> toCount(rs) > 0L } + + /** + * Retrieve the JSON text of a document, specifying the field in which the document is found + * + * @param field The field name containing the JSON document + * @param rs A `ResultSet` set to the row with the document to be constructed + * @return The JSON text of the document + */ + fun jsonFromDocument(field: String, rs: ResultSet) = + CoreResults.jsonFromDocument(field, rs) + + /** + * Retrieve the JSON text of a document, specifying the field in which the document is found + * + * @param rs A `ResultSet` set to the row with the document to be constructed + * @return The JSON text of the document + */ + fun jsonFromData(rs: ResultSet) = + CoreResults.jsonFromData(rs) + + /** + * Create a JSON array of items for the results of the given command, using the specified mapping function + * + * @param stmt The prepared statement to execute + * @param mapFunc The mapping function from data reader to JSON text + * @return A string with a JSON array of documents from the query's result + * @throws DocumentException If there is a problem executing the query (unchecked) + */ + fun toJsonArray(stmt: PreparedStatement, mapFunc: (ResultSet) -> String) = + CoreResults.toJsonArray(stmt, mapFunc) } diff --git a/src/kotlinx/src/main/kotlin/extensions/Connection.kt b/src/kotlinx/src/main/kotlin/extensions/Connection.kt index 784bb95..2b15a1b 100644 --- a/src/kotlinx/src/main/kotlin/extensions/Connection.kt +++ b/src/kotlinx/src/main/kotlin/extensions/Connection.kt @@ -19,6 +19,21 @@ inline fun Connection.customList( query: String, parameters: Collection> = listOf(), mapFunc: (ResultSet) -> TDoc ) = Custom.list(query, parameters, this, mapFunc) +/** + * Execute a query that returns a JSON array of results + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param mapFunc The mapping function to extract the JSON from the query + * @return A JSON array of results for the given query + * @throws DocumentException If parameters are invalid + */ +fun Connection.customJsonArray( + query: String, + parameters: Collection> = listOf(), + mapFunc: (ResultSet) -> String +) = Custom.jsonArray(query, parameters, mapFunc) + /** * Execute a query that returns one or no results * @@ -31,6 +46,21 @@ inline fun Connection.customSingle( query: String, parameters: Collection> = listOf(), mapFunc: (ResultSet) -> TDoc ) = Custom.single(query, parameters, this, mapFunc) +/** + * Execute a query that returns JSON for one or no documents (creates connection) + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param mapFunc The mapping function between the document and the domain item + * @return The JSON for the document if found, an empty object (`{}`) if not + * @throws DocumentException If parameters are invalid + */ +fun Connection.customJsonSingle( + query: String, + parameters: Collection> = listOf(), + mapFunc: (ResultSet) -> String +) = Custom.jsonSingle(query, parameters, mapFunc) + /** * Execute a query that returns no results * @@ -239,8 +269,7 @@ inline fun Connection.findByFields( fields: Collection>, howMatched: FieldMatch? = null, orderBy: Collection>? = null -) = - Find.byFields(tableName, fields, howMatched, orderBy, this) +) = Find.byFields(tableName, fields, howMatched, orderBy, this) /** * Retrieve documents using a JSON containment query, ordering results by the optional given fields (PostgreSQL only) @@ -255,8 +284,7 @@ inline fun Connection.findByContains( tableName: String, criteria: TContains, orderBy: Collection>? = null -) = - Find.byContains(tableName, criteria, orderBy, this) +) = Find.byContains(tableName, criteria, orderBy, this) /** * Retrieve documents using a JSON Path match query, ordering results by the optional given fields (PostgreSQL only) @@ -271,8 +299,7 @@ inline fun Connection.findByJsonPath( tableName: String, path: String, orderBy: Collection>? = null -) = - Find.byJsonPath(tableName, path, orderBy, this) +) = Find.byJsonPath(tableName, path, orderBy, this) /** * Retrieve the first document using a field comparison and optional ordering fields @@ -288,8 +315,7 @@ inline fun Connection.findFirstByFields( fields: Collection>, howMatched: FieldMatch? = null, orderBy: Collection>? = null -) = - Find.firstByFields(tableName, fields, howMatched, orderBy, this) +) = Find.firstByFields(tableName, fields, howMatched, orderBy, this) /** * Retrieve the first document using a JSON containment query and optional ordering fields (PostgreSQL only) @@ -304,8 +330,7 @@ inline fun Connection.findFirstByContain tableName: String, criteria: TContains, orderBy: Collection>? = null -) = - Find.firstByContains(tableName, criteria, orderBy, this) +) = Find.firstByContains(tableName, criteria, orderBy, this) /** * Retrieve the first document using a JSON Path match query and optional ordering fields (PostgreSQL only) @@ -320,8 +345,7 @@ inline fun Connection.findFirstByJsonPath( tableName: String, path: String, orderBy: Collection>? = null -) = - Find.firstByJsonPath(tableName, path, orderBy, this) +) = Find.firstByJsonPath(tableName, path, orderBy, this) // ~~~ DOCUMENT PATCH (PARTIAL UPDATE) QUERIES ~~~ diff --git a/src/kotlinx/src/test/kotlin/integration/CustomFunctions.kt b/src/kotlinx/src/test/kotlin/integration/CustomFunctions.kt index d7ababc..c32d52f 100644 --- a/src/kotlinx/src/test/kotlin/integration/CustomFunctions.kt +++ b/src/kotlinx/src/test/kotlin/integration/CustomFunctions.kt @@ -3,6 +3,7 @@ package solutions.bitbadger.documents.kotlinx.tests.integration import solutions.bitbadger.documents.* import solutions.bitbadger.documents.kotlinx.Results import solutions.bitbadger.documents.kotlinx.extensions.* +import solutions.bitbadger.documents.kotlinx.tests.ArrayDocument import solutions.bitbadger.documents.kotlinx.tests.JsonDocument import solutions.bitbadger.documents.kotlinx.tests.TEST_TABLE import solutions.bitbadger.documents.query.* @@ -28,6 +29,36 @@ object CustomFunctions { assertEquals(5, result.size, "There should have been 5 results") } + fun jsonArrayEmpty(db: ThrowawayDatabase) { + assertEquals(0L, db.conn.countAll(TEST_TABLE), "The test table should be empty") + assertEquals( + "[]", + db.conn.customJsonArray(FindQuery.all(TEST_TABLE), listOf(), Results::jsonFromData), + "An empty list was not represented correctly" + ) + } + + fun jsonArraySingle(db: ThrowawayDatabase) { + db.conn.insert(TEST_TABLE, ArrayDocument("one", listOf("2", "3"))) + assertEquals( + JsonFunctions.maybeJsonB("[{\"id\":\"one\",\"values\":[\"2\",\"3\"]}]"), + db.conn.customJsonArray(FindQuery.all(TEST_TABLE), listOf(), Results::jsonFromData), + "A single document list was not represented correctly" + ) + } + + fun jsonArrayMany(db: ThrowawayDatabase) { + ArrayDocument.testDocuments.forEach { db.conn.insert(TEST_TABLE, it) } + assertEquals( + JsonFunctions.maybeJsonB("[{\"id\":\"first\",\"values\":[\"a\",\"b\",\"c\"]}," + + "{\"id\":\"second\",\"values\":[\"c\",\"d\",\"e\"]}," + + "{\"id\":\"third\",\"values\":[\"x\",\"y\",\"z\"]}]"), + db.conn.customJsonArray(FindQuery.all(TEST_TABLE) + orderBy(listOf(Field.named("id"))), listOf(), + Results::jsonFromData), + "A multiple document list was not represented correctly" + ) + } + fun singleNone(db: ThrowawayDatabase) = assertNull( db.conn.customSingle(FindQuery.all(TEST_TABLE), mapFunc = Results::fromData), @@ -42,6 +73,19 @@ object CustomFunctions { ) } + fun jsonSingleNone(db: ThrowawayDatabase) = + assertEquals("{}", db.conn.customJsonSingle(FindQuery.all(TEST_TABLE), listOf(), Results::jsonFromData), + "An empty document was not represented correctly") + + fun jsonSingleOne(db: ThrowawayDatabase) { + db.conn.insert(TEST_TABLE, ArrayDocument("me", listOf("myself", "i"))) + assertEquals( + JsonFunctions.maybeJsonB("{\"id\":\"me\",\"values\":[\"myself\",\"i\"]}"), + db.conn.customJsonSingle(FindQuery.all(TEST_TABLE), listOf(), Results::jsonFromData), + "A single document was not represented correctly" + ) + } + fun nonQueryChanges(db: ThrowawayDatabase) { JsonDocument.load(db) assertEquals( diff --git a/src/kotlinx/src/test/kotlin/integration/JsonFunctions.kt b/src/kotlinx/src/test/kotlin/integration/JsonFunctions.kt new file mode 100644 index 0000000..f877497 --- /dev/null +++ b/src/kotlinx/src/test/kotlin/integration/JsonFunctions.kt @@ -0,0 +1,20 @@ +package solutions.bitbadger.documents.kotlinx.tests.integration + +import solutions.bitbadger.documents.Configuration +import solutions.bitbadger.documents.Dialect + +object JsonFunctions { + + /** + * PostgreSQL, when returning JSONB as a string, has spaces after commas and colons delineating fields and values. + * This function will do a crude string replacement to match the target string based on the dialect being tested. + * + * @param json The JSON which should be returned + * @return The actual expected JSON based on the database being tested + */ + fun maybeJsonB(json: String) = + when (Configuration.dialect()) { + Dialect.SQLITE -> json + Dialect.POSTGRESQL -> json.replace("\":\"", "\": \"").replace("\",\"", "\", \"").replace("\":[", "\": [") + } +} \ No newline at end of file diff --git a/src/kotlinx/src/test/kotlin/integration/PostgreSQLCustomIT.kt b/src/kotlinx/src/test/kotlin/integration/PostgreSQLCustomIT.kt index e1ed60c..7407c71 100644 --- a/src/kotlinx/src/test/kotlin/integration/PostgreSQLCustomIT.kt +++ b/src/kotlinx/src/test/kotlin/integration/PostgreSQLCustomIT.kt @@ -20,6 +20,21 @@ class PostgreSQLCustomIT { fun listAll() = PgDB().use(CustomFunctions::listAll) + @Test + @DisplayName("jsonArray succeeds with empty array") + fun jsonArrayEmpty() = + PgDB().use(CustomFunctions::jsonArrayEmpty) + + @Test + @DisplayName("jsonArray succeeds with a single-item array") + fun jsonArraySingle() = + PgDB().use(CustomFunctions::jsonArraySingle) + + @Test + @DisplayName("jsonArray succeeds with a multi-item array") + fun jsonArrayMany() = + PgDB().use(CustomFunctions::jsonArrayMany) + @Test @DisplayName("single succeeds when document not found") fun singleNone() = @@ -30,6 +45,16 @@ class PostgreSQLCustomIT { fun singleOne() = PgDB().use(CustomFunctions::singleOne) + @Test + @DisplayName("jsonSingle succeeds when document not found") + fun jsonSingleNone() = + PgDB().use(CustomFunctions::jsonSingleNone) + + @Test + @DisplayName("jsonSingle succeeds when a document is found") + fun jsonSingleOne() = + PgDB().use(CustomFunctions::jsonSingleOne) + @Test @DisplayName("nonQuery makes changes") fun nonQueryChanges() = diff --git a/src/kotlinx/src/test/kotlin/integration/SQLiteCustomIT.kt b/src/kotlinx/src/test/kotlin/integration/SQLiteCustomIT.kt index ce06f4c..af2e850 100644 --- a/src/kotlinx/src/test/kotlin/integration/SQLiteCustomIT.kt +++ b/src/kotlinx/src/test/kotlin/integration/SQLiteCustomIT.kt @@ -19,6 +19,21 @@ class SQLiteCustomIT { fun listAll() = SQLiteDB().use(CustomFunctions::listAll) + @Test + @DisplayName("jsonArray succeeds with empty array") + fun jsonArrayEmpty() = + SQLiteDB().use(CustomFunctions::jsonArrayEmpty) + + @Test + @DisplayName("jsonArray succeeds with a single-item array") + fun jsonArraySingle() = + SQLiteDB().use(CustomFunctions::jsonArraySingle) + + @Test + @DisplayName("jsonArray succeeds with a multi-item array") + fun jsonArrayMany() = + SQLiteDB().use(CustomFunctions::jsonArrayMany) + @Test @DisplayName("single succeeds when document not found") fun singleNone() = @@ -29,6 +44,16 @@ class SQLiteCustomIT { fun singleOne() = SQLiteDB().use(CustomFunctions::singleOne) + @Test + @DisplayName("jsonSingle succeeds when document not found") + fun jsonSingleNone() = + SQLiteDB().use(CustomFunctions::jsonSingleNone) + + @Test + @DisplayName("jsonSingle succeeds when a document is found") + fun jsonSingleOne() = + SQLiteDB().use(CustomFunctions::jsonSingleOne) + @Test @DisplayName("nonQuery makes changes") fun nonQueryChanges() = diff --git a/src/scala/src/main/scala/Custom.scala b/src/scala/src/main/scala/Custom.scala index d946b33..110562f 100644 --- a/src/scala/src/main/scala/Custom.scala +++ b/src/scala/src/main/scala/Custom.scala @@ -33,7 +33,7 @@ object Custom: */ def list[Doc](query: String, conn: Connection, mapFunc: (ResultSet, ClassTag[Doc]) => Doc) (using tag: ClassTag[Doc]): List[Doc] = - list(query, List(), conn, mapFunc) + list(query, Nil, conn, mapFunc) /** * Execute a query that returns a list of results (creates connection) @@ -59,6 +59,54 @@ object Custom: def list[Doc](query: String, mapFunc: (ResultSet, ClassTag[Doc]) => Doc)(using tag: ClassTag[Doc]): List[Doc] = list(query, List(), mapFunc) + /** + * Execute a query that returns a JSON array of results + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param conn The connection over which the query should be executed + * @param mapFunc The mapping function to extract the JSON from the query + * @return A JSON array of results for the given query + * @throws DocumentException If parameters are invalid + */ + def jsonArray(query: String, parameters: Seq[Parameter[?]], conn: Connection, mapFunc: ResultSet => String): String = + Using(Parameters.apply(conn, query, parameters)) { stmt => Results.toJsonArray(stmt, mapFunc) }.get + + /** + * Execute a query that returns a JSON array of results + * + * @param query The query to retrieve the results + * @param conn The connection over which the query should be executed + * @param mapFunc The mapping function to extract the JSON from the query + * @return A JSON array of results for the given query + * @throws DocumentException If parameters are invalid + */ + def jsonArray(query: String, conn: Connection, mapFunc: ResultSet => String): String = + jsonArray(query, Nil, conn, mapFunc) + + /** + * Execute a query that returns a JSON array of results (creates connection) + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param mapFunc The mapping function to extract the JSON from the query + * @return A JSON array of results for the given query + * @throws DocumentException If parameters are invalid + */ + def jsonArray(query: String, parameters: Seq[Parameter[?]], mapFunc: ResultSet => String): String = + Using(Configuration.dbConn()) { conn => jsonArray(query, parameters, conn, mapFunc) }.get + + /** + * Execute a query that returns a JSON array of results (creates connection) + * + * @param query The query to retrieve the results + * @param mapFunc The mapping function to extract the JSON from the query + * @return A JSON array of results for the given query + * @throws DocumentException If parameters are invalid + */ + def jsonArray(query: String, mapFunc: ResultSet => String): String = + jsonArray(query, Nil, mapFunc) + /** * Execute a query that returns one or no results * @@ -110,6 +158,57 @@ object Custom: def single[Doc](query: String, mapFunc: (ResultSet, ClassTag[Doc]) => Doc)(using tag: ClassTag[Doc]): Option[Doc] = single[Doc](query, List(), mapFunc) + /** + * Execute a query that returns JSON for one or no documents + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param conn The connection over which the query should be executed + * @param mapFunc The mapping function between the document and the domain item + * @return The JSON for the document if found, an empty object (`{}`) if not + * @throws DocumentException If parameters are invalid + */ + def jsonSingle(query: String, parameters: Seq[Parameter[?]], conn: Connection, mapFunc: ResultSet => String): String = + val result = jsonArray("$query LIMIT 1", parameters, conn, mapFunc) + result match + case "[]" => "{}" + case _ => result.substring(1, result.length - 1) + + /** + * Execute a query that returns JSON for one or no documents + * + * @param query The query to retrieve the results + * @param conn The connection over which the query should be executed + * @param mapFunc The mapping function between the document and the domain item + * @return The JSON for the document if found, an empty object (`{}`) if not + * @throws DocumentException If parameters are invalid + */ + def jsonSingle(query: String, conn: Connection, mapFunc: ResultSet => String): String = + jsonSingle(query, Nil, conn, mapFunc) + + /** + * Execute a query that returns JSON for one or no documents (creates connection) + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param mapFunc The mapping function between the document and the domain item + * @return The JSON for the document if found, an empty object (`{}`) if not + * @throws DocumentException If parameters are invalid + */ + def jsonSingle(query: String, parameters: Seq[Parameter[?]], mapFunc: ResultSet => String): String = + Using(Configuration.dbConn()) { conn => jsonSingle(query, parameters, conn, mapFunc) }.get + + /** + * Execute a query that returns JSON for one or no documents (creates connection) + * + * @param query The query to retrieve the results + * @param mapFunc The mapping function between the document and the domain item + * @return The JSON for the document if found, an empty object (`{}`) if not + * @throws DocumentException If parameters are invalid + */ + def jsonSingle(query: String, mapFunc: ResultSet => String): String = + jsonSingle(query, Nil, mapFunc) + /** * Execute a query that returns no results * diff --git a/src/scala/src/main/scala/Results.scala b/src/scala/src/main/scala/Results.scala index 18de0c1..6845076 100644 --- a/src/scala/src/main/scala/Results.scala +++ b/src/scala/src/main/scala/Results.scala @@ -75,3 +75,44 @@ object Results: */ def toExists(rs: ResultSet, tag: ClassTag[Boolean] = ClassTag.Boolean): Boolean = CoreResults.toExists(rs, Boolean.getClass) + + /** + * Retrieve the JSON text of a document, specifying the field in which the document is found + * + * @param field The field name containing the JSON document + * @param rs A `ResultSet` set to the row with the document to be constructed + * @return The JSON text of the document + */ + def jsonFromDocument(field: String, rs: ResultSet): String = + CoreResults.jsonFromDocument(field, rs) + + /** + * Retrieve the JSON text of a document, specifying the field in which the document is found + * + * @param rs A `ResultSet` set to the row with the document to be constructed + * @return The JSON text of the document + */ + def jsonFromData(rs: ResultSet): String = + CoreResults.jsonFromData(rs) + + /** + * Create a JSON array of items for the results of the given command, using the specified mapping function + * + * @param stmt The prepared statement to execute + * @param mapFunc The mapping function from data reader to JSON text + * @return A string with a JSON array of documents from the query's result + * @throws DocumentException If there is a problem executing the query (unchecked) + */ + def toJsonArray(stmt: PreparedStatement, mapFunc: ResultSet => String): String = + try + val results = StringBuilder("[") + Using(stmt.executeQuery()) { rs => + while (rs.next()) { + if (results.length > 2) results.append(",") + results.append(mapFunc(rs)) + } + } + results.append("]").toString() + catch + case ex: SQLException => + throw DocumentException("Error retrieving documents from query: ${ex.message}", ex) diff --git a/src/scala/src/main/scala/extensions/package.scala b/src/scala/src/main/scala/extensions/package.scala index c7c4dd6..75275f6 100644 --- a/src/scala/src/main/scala/extensions/package.scala +++ b/src/scala/src/main/scala/extensions/package.scala @@ -35,6 +35,29 @@ extension (conn: Connection) (using tag: ClassTag[Doc]): List[Doc] = Custom.list[Doc](query, conn, mapFunc) + /** + * Execute a query that returns a JSON array of results + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param mapFunc The mapping function to extract the JSON from the query + * @return A JSON array of results for the given query + * @throws DocumentException If parameters are invalid + */ + def customJsonArray(query: String, parameters: Seq[Parameter[?]], mapFunc: ResultSet => String): String = + Custom.jsonArray(query, parameters, conn, mapFunc) + + /** + * Execute a query that returns a JSON array of results + * + * @param query The query to retrieve the results + * @param mapFunc The mapping function to extract the JSON from the query + * @return A JSON array of results for the given query + * @throws DocumentException If parameters are invalid + */ + def customJsonArray(query: String, mapFunc: ResultSet => String): String = + Custom.jsonArray(query, mapFunc) + /** * Execute a query that returns one or no results * @@ -60,6 +83,29 @@ extension (conn: Connection) (using tag: ClassTag[Doc]): Option[Doc] = Custom.single[Doc](query, conn, mapFunc) + /** + * Execute a query that returns JSON for one or no documents (creates connection) + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param mapFunc The mapping function between the document and the domain item + * @return The JSON for the document if found, an empty object (`{}`) if not + * @throws DocumentException If parameters are invalid + */ + def customJsonSingle(query: String, parameters: Seq[Parameter[?]], mapFunc: ResultSet => String): String = + Custom.jsonSingle(query, parameters, conn, mapFunc) + + /** + * Execute a query that returns JSON for one or no documents (creates connection) + * + * @param query The query to retrieve the results + * @param mapFunc The mapping function between the document and the domain item + * @return The JSON for the document if found, an empty object (`{}`) if not + * @throws DocumentException If parameters are invalid + */ + def customJsonSingle(query: String, mapFunc: ResultSet => String): String = + Custom.jsonSingle(query, mapFunc) + /** * Execute a query that returns no results * diff --git a/src/scala/src/test/scala/integration/CustomFunctions.scala b/src/scala/src/test/scala/integration/CustomFunctions.scala index 0e6f992..32748fe 100644 --- a/src/scala/src/test/scala/integration/CustomFunctions.scala +++ b/src/scala/src/test/scala/integration/CustomFunctions.scala @@ -1,12 +1,14 @@ package solutions.bitbadger.documents.scala.tests.integration import org.junit.jupiter.api.Assertions.* -import solutions.bitbadger.documents.query.{CountQuery, DeleteQuery, FindQuery} +import solutions.bitbadger.documents.query.{CountQuery, DeleteQuery, FindQuery, QueryUtils} import solutions.bitbadger.documents.scala.Results import solutions.bitbadger.documents.scala.extensions.* import solutions.bitbadger.documents.scala.tests.TEST_TABLE import solutions.bitbadger.documents.{Configuration, Field, Parameter, ParameterType} +import scala.jdk.CollectionConverters.* + object CustomFunctions: def listEmpty(db: ThrowawayDatabase): Unit = @@ -20,6 +22,26 @@ object CustomFunctions: val result = db.conn.customList[JsonDocument](FindQuery.all(TEST_TABLE), Results.fromData) assertEquals(5, result.size, "There should have been 5 results") + def jsonArrayEmpty(db: ThrowawayDatabase): Unit = + assertEquals(0L, db.conn.countAll(TEST_TABLE), "The test table should be empty") + assertEquals("[]", db.conn.customJsonArray(FindQuery.all(TEST_TABLE), Nil, Results.jsonFromData), + "An empty list was not represented correctly") + + def jsonArraySingle(db: ThrowawayDatabase): Unit = + db.conn.insert(TEST_TABLE, ArrayDocument("one", "2" :: "3" :: Nil)) + assertEquals(JsonFunctions.maybeJsonB("[{\"id\":\"one\",\"values\":[\"2\",\"3\"]}]"), + db.conn.customJsonArray(FindQuery.all(TEST_TABLE), Nil, Results.jsonFromData), + "A single document list was not represented correctly") + + def jsonArrayMany(db: ThrowawayDatabase): Unit = + ArrayDocument.testDocuments.foreach { doc => db.conn.insert(TEST_TABLE, doc) } + assertEquals(JsonFunctions.maybeJsonB("[{\"id\":\"first\",\"values\":[\"a\",\"b\",\"c\"]}," + + "{\"id\":\"second\",\"values\":[\"c\",\"d\",\"e\"]}," + + "{\"id\":\"third\",\"values\":[\"x\",\"y\",\"z\"]}]"), + db.conn.customJsonArray(FindQuery.all(TEST_TABLE) + QueryUtils.orderBy((Field.named("id") :: Nil).asJava), Nil, + Results.jsonFromData), + "A multiple document list was not represented correctly") + def singleNone(db: ThrowawayDatabase): Unit = assertTrue(db.conn.customSingle[JsonDocument](FindQuery.all(TEST_TABLE), Results.fromData).isEmpty, "There should not have been a document returned") @@ -29,6 +51,16 @@ object CustomFunctions: assertTrue(db.conn.customSingle[JsonDocument](FindQuery.all(TEST_TABLE), Results.fromData).isDefined, "There should have been a document returned") + def jsonSingleNone(db: ThrowawayDatabase): Unit = + assertEquals("{}", db.conn.customJsonSingle(FindQuery.all(TEST_TABLE), Nil, Results.jsonFromData), + "An empty document was not represented correctly") + + def jsonSingleOne(db: ThrowawayDatabase): Unit = + db.conn.insert(TEST_TABLE, ArrayDocument("me", "myself" :: "i" :: Nil)) + assertEquals(JsonFunctions.maybeJsonB("{\"id\":\"me\",\"values\":[\"myself\",\"i\"]}"), + db.conn.customJsonSingle(FindQuery.all(TEST_TABLE), Nil, Results.jsonFromData), + "A single document was not represented correctly") + def nonQueryChanges(db: ThrowawayDatabase): Unit = JsonDocument.load(db) assertEquals(5L, db.conn.customScalar[Long](CountQuery.all(TEST_TABLE), Results.toCount), diff --git a/src/scala/src/test/scala/integration/JsonFunctions.scala b/src/scala/src/test/scala/integration/JsonFunctions.scala new file mode 100644 index 0000000..a8cfc97 --- /dev/null +++ b/src/scala/src/test/scala/integration/JsonFunctions.scala @@ -0,0 +1,17 @@ +package solutions.bitbadger.documents.scala.tests.integration + +import solutions.bitbadger.documents.{Configuration, Dialect} + +object JsonFunctions: + + /** + * PostgreSQL, when returning JSONB as a string, has spaces after commas and colons delineating fields and values. + * This function will do a crude string replacement to match the target string based on the dialect being tested. + * + * @param json The JSON which should be returned + * @return The actual expected JSON based on the database being tested + */ + def maybeJsonB(json: String): String = + Configuration.dialect() match + case Dialect.SQLITE => json + case Dialect.POSTGRESQL => json.replace("\":\"", "\": \"").replace("\",\"", "\", \"").replace("\":[", "\": [") diff --git a/src/scala/src/test/scala/integration/PostgreSQLCustomIT.scala b/src/scala/src/test/scala/integration/PostgreSQLCustomIT.scala index 8ad437b..9c0c131 100644 --- a/src/scala/src/test/scala/integration/PostgreSQLCustomIT.scala +++ b/src/scala/src/test/scala/integration/PostgreSQLCustomIT.scala @@ -17,6 +17,21 @@ class PostgreSQLCustomIT: def listAll(): Unit = Using(PgDB()) { db => CustomFunctions.listAll(db) } + @Test + @DisplayName("jsonArray succeeds with empty array") + def jsonArrayEmpty(): Unit = + Using(PgDB()) { db => CustomFunctions.jsonArrayEmpty(db) } + + @Test + @DisplayName("jsonArray succeeds with a single-item array") + def jsonArraySingle(): Unit = + Using(PgDB()) { db => CustomFunctions.jsonArraySingle(db) } + + @Test + @DisplayName("jsonArray succeeds with a multi-item array") + def jsonArrayMany(): Unit = + Using(PgDB()) { db => CustomFunctions.jsonArrayMany(db) } + @Test @DisplayName("single succeeds when document not found") def singleNone(): Unit = @@ -27,6 +42,16 @@ class PostgreSQLCustomIT: def singleOne(): Unit = Using(PgDB()) { db => CustomFunctions.singleOne(db) } + @Test + @DisplayName("jsonSingle succeeds when document not found") + def jsonSingleNone(): Unit = + Using(PgDB()) { db => CustomFunctions.jsonSingleNone(db) } + + @Test + @DisplayName("jsonSingle succeeds when a document is found") + def jsonSingleOne(): Unit = + Using(PgDB()) { db => CustomFunctions.jsonSingleOne(db) } + @Test @DisplayName("nonQuery makes changes") def nonQueryChanges(): Unit = diff --git a/src/scala/src/test/scala/integration/SQLiteCustomIT.scala b/src/scala/src/test/scala/integration/SQLiteCustomIT.scala index f393a1e..dfbc0ca 100644 --- a/src/scala/src/test/scala/integration/SQLiteCustomIT.scala +++ b/src/scala/src/test/scala/integration/SQLiteCustomIT.scala @@ -17,6 +17,21 @@ class SQLiteCustomIT: def listAll(): Unit = Using(SQLiteDB()) { db => CustomFunctions.listAll(db) } + @Test + @DisplayName("jsonArray succeeds with empty array") + def jsonArrayEmpty(): Unit = + Using(SQLiteDB()) { db => CustomFunctions.jsonArrayEmpty(db) } + + @Test + @DisplayName("jsonArray succeeds with a single-item array") + def jsonArraySingle(): Unit = + Using(SQLiteDB()) { db => CustomFunctions.jsonArraySingle(db) } + + @Test + @DisplayName("jsonArray succeeds with a multi-item array") + def jsonArrayMany(): Unit = + Using(SQLiteDB()) { db => CustomFunctions.jsonArrayMany(db) } + @Test @DisplayName("single succeeds when document not found") def singleNone(): Unit = @@ -27,6 +42,16 @@ class SQLiteCustomIT: def singleOne(): Unit = Using(SQLiteDB()) { db => CustomFunctions.singleOne(db) } + @Test + @DisplayName("jsonSingle succeeds when document not found") + def jsonSingleNone(): Unit = + Using(SQLiteDB()) { db => CustomFunctions.jsonSingleNone(db) } + + @Test + @DisplayName("jsonSingle succeeds when a document is found") + def jsonSingleOne(): Unit = + Using(SQLiteDB()) { db => CustomFunctions.jsonSingleOne(db) } + @Test @DisplayName("nonQuery makes changes") def nonQueryChanges(): Unit =