diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2982b7..9bfa1d9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,6 +69,7 @@ jobs: psql postgresql://postgres:postgres@localhost:5432/mag_db -f balta/src/test/resources/db/postgres/05_testing._base_types_data.sql psql postgresql://postgres:postgres@localhost:5432/mag_db -f balta/src/test/resources/db/postgres/06_testing.pg_types.ddl psql postgresql://postgres:postgres@localhost:5432/mag_db -f balta/src/test/resources/db/postgres/07_testing_pg_types_data.sql + psql postgresql://postgres:postgres@localhost:5432/mag_db -f balta/src/test/resources/db/postgres/08_testing.simple_function.sql - name: Build and run integration tests run: sbt ++${{matrix.scala}} testIT diff --git a/.github/workflows/jacoco_report.yml b/.github/workflows/jacoco_report.yml index 2ad5cf8..01af369 100644 --- a/.github/workflows/jacoco_report.yml +++ b/.github/workflows/jacoco_report.yml @@ -46,6 +46,7 @@ jobs: psql postgresql://postgres:postgres@localhost:5432/mag_db -f balta/src/test/resources/db/postgres/05_testing._base_types_data.sql psql postgresql://postgres:postgres@localhost:5432/mag_db -f balta/src/test/resources/db/postgres/06_testing.pg_types.ddl psql postgresql://postgres:postgres@localhost:5432/mag_db -f balta/src/test/resources/db/postgres/07_testing_pg_types_data.sql + psql postgresql://postgres:postgres@localhost:5432/mag_db -f balta/src/test/resources/db/postgres/08_testing.simple_function.sql - name: Build and run tests continue-on-error: true diff --git a/.sbtrc b/.sbtrc index 5f69dcb..944c0cb 100644 --- a/.sbtrc +++ b/.sbtrc @@ -25,3 +25,5 @@ alias test=; testOnly *UnitTests # * Integration tests alias testIT=; testOnly *IntegrationTests +# Run all tests +alias testAll=; testOnly * diff --git a/README.md b/README.md index f99f09d..2edf469 100644 --- a/README.md +++ b/README.md @@ -27,17 +27,71 @@ It's a natural complement to the use of [Fa-Db library](https://github.com/AbsaO * The transaction start and rollback are done automatically before or after the execution respectively of the `test` function provided -Advantages of this approach is that the tests repeateble, they are isolated from each other and the database is always +Advantages of this approach is that the tests are repeatable, they are isolated from each other and the database is always in a known state before and after each test. -## How to Test -There are integration tests part of the package that can be run with the following command: +## How to write tests + +### [`DBTestSuite`](https://github.com/AbsaOSS/balta/blob/master/balta/src/main/scala/za/co/absa/db/balta/DBTestSuite.scala) class + +The foundation is the [`DBTestSuite`](https://github.com/AbsaOSS/balta/blob/master/balta/src/main/scala/za/co/absa/db/balta/DBTestSuite.scala) +class that provides the necessary setup and teardown for the tests to each run in its own transaction. It is an +enhancement class to standard ScalaTest `AnyFunSuite` class. + +Besides that, it provides easy access to tables, queries them and inserts data into them. + +And it allows easy access to database functions and executing them. + +### [`DBTable`](https://github.com/AbsaOSS/balta/blob/master/balta/src/main/scala/za/co/absa/db/balta/classes/DBTable.scala) class + +Class for representing a database table. It provides methods for querying the table and inserting data into it. The class +instance is spawned by each `DBTestSuite.table` method call. + +### [`DBFunction`](https://github.com/AbsaOSS/balta/blob/master/balta/src/main/scala/za/co/absa/db/balta/classes/DBFunction.scala) class + +Class for representing a database function. It provides methods for calling the function and verifying the return values. +The class instance is spawned by each `DBTestSuite.function` method call. + +### [`QueryResult`](https://github.com/AbsaOSS/balta/blob/master/balta/src/main/scala/za/co/absa/db/balta/classes/QueryResult.scala) class + +This iterator represents the result of a query. It provides methods for iterating over the rows and columns of the result. + +### [`QueryResultRow`](https://github.com/AbsaOSS/balta/blob/master/balta/src/main/scala/za/co/absa/db/balta/classes/QueryResultRow.scala) class + +This class represents a row in a query result. It provides methods for accessing the columns of the row - via name or index. + +To make specific Postgres types available in `QueryResultRow` there is the implicit class +[`Postgres.PostgresRow`](https://github.com/AbsaOSS/balta/blob/master/balta/src/main/scala/za/co/absa/db/balta/implicits/Postgres.scala) +enhancing the `QueryResultRow` with the ability to get type like `JSON` (including JSONB) and `JSON[]` (JSON array). + +There is also an implicit class +[`QueryResultImplicits.ProductTypeConvertor`](https://github.com/AbsaOSS/balta/blob/master/balta/src/main/scala/za/co/absa/db/balta/implicits/QueryResultImplicits.scala) +enhancing the `QueryResultRow` with the ability to convert the row to a product type (case class or tuple) via function +`toProductType[T]`. + + + + + +## How to test the library + +Use the `test` command to execute all unit tests, skipping all other types of tests. +```bash +sbt test +``` + +There are integration tests part of the package that can be run with the following command: ```bash sbt testIT ``` -The tests to finish successfully, a Postgres database must be running and populated. +If you want to run all tests, use the following command. +```bash +sbt testAll +``` + +The integrations tests to finish successfully, a Postgres database must be running and populated. * by default the database is expected to be running on `localhost:5432` * if you wish to run against a different server modify the `src/test/resources/database.properties` file * to populate the database run the scripts in the `src/test/resources/db/postgres` folder @@ -59,3 +113,4 @@ Please see [this file](RELEASE.md) for more details. ### Postgres * `TIMESTAMP WITH TIME ZONE[]`, `TIME WITH TIME ZONE[]`, generally arrays of time related types are not translated to appropriate time zone aware Scala/Java types +* Conversion to product type won't work if there are complex members in the product type like subclasses; with container types like `List`, it hasn't been tested \ No newline at end of file diff --git a/balta/src/main/scala/za/co/absa/db/balta/classes/QueryResultRow.scala b/balta/src/main/scala/za/co/absa/db/balta/classes/QueryResultRow.scala index d19ab6f..ad0ab5a 100644 --- a/balta/src/main/scala/za/co/absa/db/balta/classes/QueryResultRow.scala +++ b/balta/src/main/scala/za/co/absa/db/balta/classes/QueryResultRow.scala @@ -17,6 +17,7 @@ package za.co.absa.db.balta.classes import QueryResultRow._ +import za.co.absa.db.balta.implicits.MapImplicits.MapEnhancements import java.sql import java.sql.{Date, ResultSet, ResultSetMetaData, Time, Types} @@ -26,16 +27,21 @@ import java.util.UUID /** * This is a row of a query result. It allows to safely extract values from the row by column name. * - * @param rowNumber - the number of the row in the result set - * @param fields - the values of the row - * @param columnNames - the names of the columns + * @param rowNumber - the number of the row in the result set + * @param fields - the values of the row + * @param columnLabels - the names of the columns; class uses `columnLabel(s)` to refer to the column names in accordance + * to `java.sql.ResultSet`, which is is build around and that despite that aliases are not expected + * to appear here */ class QueryResultRow private[classes](val rowNumber: Int, private val fields: Vector[Option[Object]], - private val columnNames: FieldNames) { + private val columnLabels: FieldNames) { def columnCount: Int = fields.length - def columnNumber(columnLabel: String): Int = columnNames(columnLabel.toLowerCase) + def columnNumber(columnLabel: String): Int = { + val actualLabel = columnLabel.toLowerCase + columnLabels.getOrThrow(actualLabel, new NoSuchElementException(s"Column '$actualLabel' not found")) + } /** * Extracts a value from the row by column number. @@ -131,7 +137,6 @@ class QueryResultRow private[classes](val rowNumber: Int, getAs(column: Int, transformerFnc _) } - } object QueryResultRow { diff --git a/balta/src/main/scala/za/co/absa/db/balta/implicits/MapImplicits.scala b/balta/src/main/scala/za/co/absa/db/balta/implicits/MapImplicits.scala new file mode 100644 index 0000000..99d3726 --- /dev/null +++ b/balta/src/main/scala/za/co/absa/db/balta/implicits/MapImplicits.scala @@ -0,0 +1,33 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.balta.implicits + +object MapImplicits { + implicit class MapEnhancements[K, V](val map: Map[K, V]) extends AnyVal { + /** + * Gets the value associated with the key or throws the provided exception + * @param key - the key to get the value for + * @param exception - the exception to throw in case the `option` is None + * @tparam V1 - the type of the value + * @return - the value associated with key if it exists, otherwise throws the provided exception + */ + def getOrThrow[V1 >: V](key: K, exception: => Throwable): V1 = { + map.getOrElse(key, throw exception) + } + } + +} diff --git a/balta/src/main/scala/za/co/absa/db/balta/implicits/QueryResultRowImplicits.scala b/balta/src/main/scala/za/co/absa/db/balta/implicits/QueryResultRowImplicits.scala new file mode 100644 index 0000000..8535611 --- /dev/null +++ b/balta/src/main/scala/za/co/absa/db/balta/implicits/QueryResultRowImplicits.scala @@ -0,0 +1,90 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.balta.implicits + +import za.co.absa.db.balta.classes.QueryResultRow +import za.co.absa.db.balta.implicits.OptionImplicits.OptionEnhancements +import za.co.absa.db.mag.naming.NamingConvention + +import scala.reflect.runtime.currentMirror +import scala.reflect.runtime.universe._ + +object QueryResultRowImplicits { + + def isOptionType(typeToCheck: Type): Boolean = { + typeToCheck <:< typeOf[Option[_]] + } + + /** + * This class provides an implicit conversion from QueryResultRow to a case class + * This logic placed in an implicit class to prevent polluting the QueryResultRow class with too much unrelated logic + * @param row The QueryResultRow to convert + */ + implicit class ProductTypeConvertor(val row: QueryResultRow) extends AnyVal { + + /** + * Converts a QueryResultRow to a case class + * @param namingConvention - The naming convention to use when converting field names to column names + * @tparam T - The case class to convert to + * @return - The case class instance filled with data from the QueryResultRow + */ + def toProductType[T <: Product : TypeTag](implicit namingConvention: NamingConvention): T = { + val tpe = typeOf[T] + val defaultConstructor = getConstructor(tpe) + val constructorMirror = getConstructorMirror(tpe, defaultConstructor) + val params = readParamsFromRow(defaultConstructor) + constructorMirror(params: _*).asInstanceOf[T] + } + + private def getConstructor(tpe: Type): MethodSymbol = { + val constructorSymbol = tpe.decl(termNames.CONSTRUCTOR) + val defaultConstructor = + if (constructorSymbol.isMethod) constructorSymbol.asMethod + else { + val ctors = constructorSymbol.asTerm.alternatives + ctors.map(_.asMethod).find(_.isPrimaryConstructor).get + } + defaultConstructor + } + + private def getConstructorMirror(tpe: Type, constructor: MethodSymbol): MethodMirror = { + val classSymbol = tpe.typeSymbol.asClass + val classMirror = currentMirror.reflectClass(classSymbol) + val constructorMirror = classMirror.reflectConstructor(constructor) + constructorMirror + } + + private def readParamsFromRow(constructor: MethodSymbol)(implicit namingConvention: NamingConvention): List[Any] = { + constructor.paramLists.flatten.map { param => + val name = param.name.decodedName.toString + val paramType = param.typeSignature + val columnLabel = namingConvention.stringPerConvention(name) + getParamValue(columnLabel, paramType) + } + + } + + private def getParamValue[T: TypeTag](columnLabel: String, expectedType: Type): Any = { + val value = row(columnLabel) + if (isOptionType(expectedType)) { + value + } else { + value.getOrThrow(new NullPointerException(s"Column '$columnLabel' is null")) + } + } + } +} diff --git a/balta/src/main/scala/za/co/absa/db/mag/exceptions/NamingException.scala b/balta/src/main/scala/za/co/absa/db/mag/exceptions/NamingException.scala new file mode 100644 index 0000000..cf8101d --- /dev/null +++ b/balta/src/main/scala/za/co/absa/db/mag/exceptions/NamingException.scala @@ -0,0 +1,22 @@ +/* + * Copyright 2022 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.exceptions + +/** + * Exception thrown when a naming convention is not found for a given string + */ +case class NamingException(message: String) extends Exception(message) diff --git a/balta/src/main/scala/za/co/absa/db/mag/naming/LettersCase.scala b/balta/src/main/scala/za/co/absa/db/mag/naming/LettersCase.scala new file mode 100644 index 0000000..e05a74e --- /dev/null +++ b/balta/src/main/scala/za/co/absa/db/mag/naming/LettersCase.scala @@ -0,0 +1,55 @@ +/* + * Copyright 2022 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming + +/** + * `LettersCase` is a sealed trait that represents different cases of letters. + * It provides a method to convert a string to the specific case. + */ +sealed trait LettersCase { + + /** + * Converts a string to the specific case. + * @param s - The original string. + * @return The string converted to the specific case. + */ + def convert(s: String): String +} + +object LettersCase { + + /** + * `AsIs` is a [[LettersCase]] that leaves strings as they are. + */ + case object AsIs extends LettersCase { + override def convert(s: String): String = s + } + + /** + * `LowerCase` is a [[LettersCase]] that converts strings to lower case. + */ + case object LowerCase extends LettersCase { + override def convert(s: String): String = s.toLowerCase + } + + /** + * `UpperCase` is a [[LettersCase]] that converts strings to upper case. + */ + case object UpperCase extends LettersCase { + override def convert(s: String): String = s.toUpperCase + } +} diff --git a/balta/src/main/scala/za/co/absa/db/mag/naming/NamingConvention.scala b/balta/src/main/scala/za/co/absa/db/mag/naming/NamingConvention.scala new file mode 100644 index 0000000..68245fa --- /dev/null +++ b/balta/src/main/scala/za/co/absa/db/mag/naming/NamingConvention.scala @@ -0,0 +1,54 @@ +/* + * Copyright 2022 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming + +/** + * `NamingConvention` is a base trait that defines the interface for different naming conventions. + * It provides methods to convert a class name according to given naming convention. + */ +trait NamingConvention { + + /** + * Converts the class name according to the specific naming convention. + * @param c - The class. + * @return The class name converted to string according to the specific naming convention. + */ + def fromClassNamePerConvention(c: Class[_]): String = { + val className = c.getSimpleName + val cleanClassName = className.lastIndexOf('$') match { + case -1 => className + case x => className.substring(0, x) + } + stringPerConvention(cleanClassName) + } + + /** + * Converts the class name according to the specific naming convention. + * @param instance - The instance of the class. + * @return The class name converted to string according to the specific naming convention. + */ + def fromClassNamePerConvention(instance: AnyRef): String = { + fromClassNamePerConvention(instance.getClass) + } + + /** + * Converts the original string according to the specific naming convention. + * @param original - The original string. + * @return The original string converted according to the specific naming convention. + */ + def stringPerConvention(original: String): String +} diff --git a/balta/src/main/scala/za/co/absa/db/mag/naming/implementations/AsIsNaming.scala b/balta/src/main/scala/za/co/absa/db/mag/naming/implementations/AsIsNaming.scala new file mode 100644 index 0000000..e978139 --- /dev/null +++ b/balta/src/main/scala/za/co/absa/db/mag/naming/implementations/AsIsNaming.scala @@ -0,0 +1,50 @@ +/* + * Copyright 2022 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import za.co.absa.db.mag.naming.LettersCase.AsIs +import za.co.absa.db.mag.naming.{LettersCase, NamingConvention} + +/** + * `AsIsNaming` provides a naming convention that leaves strings as they are. + * It implements the [[NamingConvention]] trait. + * @param lettersCase - The case of the letters in the string. + */ +class AsIsNaming(lettersCase: LettersCase) extends NamingConvention { + + /** + * Returns the original string converted to the specified letter case. + * @param original - The original string. + * @return The original string converted to the specified letter case. + */ + override def stringPerConvention(original: String): String = { + lettersCase.convert(original) + } +} + +/** + * `AsIsNaming.Implicits` provides an implicit [[NamingConvention]] instance that leaves strings as they are. + */ +object AsIsNaming { + object Implicits { + + /** + * An implicit [[NamingConvention]] instance that leaves strings as they are. + */ + implicit val namingConvention: NamingConvention = new AsIsNaming(AsIs) + } +} diff --git a/balta/src/main/scala/za/co/absa/db/mag/naming/implementations/ExplicitNamingRequired.scala b/balta/src/main/scala/za/co/absa/db/mag/naming/implementations/ExplicitNamingRequired.scala new file mode 100644 index 0000000..a1902d0 --- /dev/null +++ b/balta/src/main/scala/za/co/absa/db/mag/naming/implementations/ExplicitNamingRequired.scala @@ -0,0 +1,51 @@ +/* + * Copyright 2022 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import za.co.absa.db.mag.exceptions.NamingException +import za.co.absa.db.mag.naming.NamingConvention + +/** + * `ExplicitNamingRequired` is a `NamingConvention` that throws a `NamingException` for any string. + * This is used when explicit naming is required and no other naming convention should be applied. + */ +class ExplicitNamingRequired extends NamingConvention { + + /** + * Throws a `NamingConvention` with a message indicating that explicit naming is required. + * @param original - The original string. + * @return Nothing, as a `NamingException` is always thrown. + */ + override def stringPerConvention(original: String): String = { + val message = s"No convention for '$original', explicit naming required." + throw NamingException(message) + } +} + +/** + * `ExplicitNamingRequired.Implicits` provides an implicit `NamingConvention` instance that + * throws a `NamingException` for any string. + */ +object ExplicitNamingRequired { + object Implicits { + + /** + * An implicit `NamingConvention` instance that throws a `NamingException` for any string. + */ + implicit val namingConvention: NamingConvention = new ExplicitNamingRequired() + } +} diff --git a/balta/src/main/scala/za/co/absa/db/mag/naming/implementations/MapBasedNaming.scala b/balta/src/main/scala/za/co/absa/db/mag/naming/implementations/MapBasedNaming.scala new file mode 100644 index 0000000..bcb40b6 --- /dev/null +++ b/balta/src/main/scala/za/co/absa/db/mag/naming/implementations/MapBasedNaming.scala @@ -0,0 +1,52 @@ +/* + * Copyright 2022 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import za.co.absa.db.balta.implicits.MapImplicits.MapEnhancements +import za.co.absa.db.mag.exceptions.NamingException +import za.co.absa.db.mag.naming.LettersCase.AsIs +import za.co.absa.db.mag.naming.{LettersCase, NamingConvention} + +/** + * `MapBasedNaming` requires an explicit map of name conversions provided in a form of a `Map[String, String]`. + * If the requested name is not found in the map, a `NamingException` is thrown. + */ +class MapBasedNaming private(names: Map[String, String], lettersCase: LettersCase) extends NamingConvention { + + /** + * Throws a `NamingConvention` if the original is not present between the keys of the Map. + * @param original - The original string. + * @return - The string from the map linked to the original string. + */ + override def stringPerConvention(original: String): String = { + names.getOrThrow(lettersCase.convert(original), NamingException(s"No convention for '$original' has been defined.")) + } +} + +object MapBasedNaming { + /** + * Creates a new `MapBasedNaming` instance with the specified names and letter cases. + * @param names - The map of names. + * @param keysLettersCase - The case of the keys in the map. Input values are converted to this case upon querying. + * @param valueLettersCase - The case of the values in the map. + * @return - The string from the map linked to the original string. + */ + def apply(names: Map[String, String], keysLettersCase: LettersCase = AsIs, valueLettersCase: LettersCase = AsIs): NamingConvention = { + val actualNames = names.map { case (k, v) => (keysLettersCase.convert(k), valueLettersCase.convert(v)) } + new MapBasedNaming(actualNames, keysLettersCase) + } +} diff --git a/balta/src/main/scala/za/co/absa/db/mag/naming/implementations/SnakeCaseNaming.scala b/balta/src/main/scala/za/co/absa/db/mag/naming/implementations/SnakeCaseNaming.scala new file mode 100644 index 0000000..b534fe2 --- /dev/null +++ b/balta/src/main/scala/za/co/absa/db/mag/naming/implementations/SnakeCaseNaming.scala @@ -0,0 +1,65 @@ +/* + * Copyright 2022 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import za.co.absa.db.mag.naming.LettersCase.LowerCase +import za.co.absa.db.mag.naming.{LettersCase, NamingConvention} + +/** + * `SnakeCaseNaming` provides a naming convention that converts camel case strings to snake case. + * It implements the [[NamingConvention]] trait. + * + * @param lettersCase - The case of the letters in the string. + */ +class SnakeCaseNaming(lettersCase: LettersCase) extends NamingConvention { + + private def camelCaseToSnakeCase(s: String): String = { + s.replaceAll("([A-Z])", "_$1") + } + + private def stripIfFirstChar(s: String, ch: Char): String = { + if (s == "") { + s + } else if (s(0) == ch) { + s.substring(1) + } else { + s + } + } + + /** + * Converts the original string to snake case and the specified letter case. + * @param original - The original string. + * @return The original string converted to snake case and the specified letter case. + */ + override def stringPerConvention(original: String): String = { + lettersCase.convert(stripIfFirstChar(camelCaseToSnakeCase(original), '_')) + } +} + +/** + * `SnakeCaseNaming.Implicits` provides an implicit [[NamingConvention]] instance that converts camel case strings to snake case. + */ +object SnakeCaseNaming { + object Implicits { + + /** + * An implicit [[NamingConvention]] instance that converts camel case strings to snake case. + */ + implicit val namingConvention: NamingConvention = new SnakeCaseNaming(LowerCase) + } +} diff --git a/balta/src/test/resources/db/postgres/08_testing.simple_function.sql b/balta/src/test/resources/db/postgres/08_testing.simple_function.sql new file mode 100644 index 0000000..c5ec58a --- /dev/null +++ b/balta/src/test/resources/db/postgres/08_testing.simple_function.sql @@ -0,0 +1,55 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +CREATE OR REPLACE FUNCTION testing.simple_function( + IN i_return_data BOOLEAN, + OUT int_data INTEGER, + OUT text_data TEXT, + OUT timestamp_data TIMESTAMP WITH TIME ZONE, + OUT uuid_data UUID, + OUT ignored_data TEXT +) RETURNS record AS +$$ +------------------------------------------------------------------------------- +-- +-- Function: testing.simple_function(1) +-- Function returning static data for testing purposes +-- +-- Parameters: +-- i_return_data - flag if data are to be returned or not +-- +-- Returns: +-- sample data +-- +------------------------------------------------------------------------------- +DECLARE +BEGIN + ignored_data := 'This is to be ignored'; + + IF i_return_data THEN + int_data := 42; + text_data := 'Hello World!'; + timestamp_data := '2023-01-01 00:00:00+00'::TIMESTAMP WITH TIME ZONE; + uuid_data := 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'::UUID; + END IF; + + RETURN; +END; +$$ + LANGUAGE plpgsql VOLATILE + SECURITY DEFINER; + +GRANT EXECUTE ON FUNCTION testing.simple_function(BOOLEAN) TO mag_owner; diff --git a/balta/src/test/scala/za/co/absa/db/balta/implicits/MapImplicitsUnitTests.scala b/balta/src/test/scala/za/co/absa/db/balta/implicits/MapImplicitsUnitTests.scala new file mode 100644 index 0000000..62607ae --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/balta/implicits/MapImplicitsUnitTests.scala @@ -0,0 +1,34 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.balta.implicits + +import org.scalatest.funsuite.AnyFunSuiteLike +import za.co.absa.db.balta.implicits.MapImplicits.MapEnhancements + + +class MapImplicitsUnitTests extends AnyFunSuiteLike { + private val map = Map("foo" -> 1, "bar" -> 2) + + test("getOrThrow returns the value if it is defined") { + assert(map.getOrThrow("foo", new Exception("Foo")) == 1) + } + + test("getOrThrow throws an exception if the value is not defined") { + assertThrows[Exception](map.getOrThrow("barbar", new Exception("Foo"))) + } + +} diff --git a/balta/src/test/scala/za/co/absa/db/balta/implicits/QueryResultRowImplicitsIntegrationTests.scala b/balta/src/test/scala/za/co/absa/db/balta/implicits/QueryResultRowImplicitsIntegrationTests.scala new file mode 100644 index 0000000..65e8b0d --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/balta/implicits/QueryResultRowImplicitsIntegrationTests.scala @@ -0,0 +1,136 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.balta.implicits + +import java.time.OffsetDateTime +import java.util.UUID +import org.scalatest.funsuite.AnyFunSuiteLike +import za.co.absa.db.balta.classes.DBFunction +import za.co.absa.db.balta.implicits.QueryResultRowImplicits.ProductTypeConvertor +import za.co.absa.db.balta.testing.classes.DBTestingConnection +import za.co.absa.db.mag.naming.NamingConvention +import za.co.absa.db.mag.naming.implementations.MapBasedNaming + + +class QueryResultRowImplicitsIntegrationTests extends AnyFunSuiteLike with DBTestingConnection{ + private val function = DBFunction("testing.simple_function") + private val timestamp = OffsetDateTime.parse("2023-01-01T00:00:00Z") + private val uuid = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") + + test("Product type with Option types is created with actual values") { + import za.co.absa.db.mag.naming.implementations.SnakeCaseNaming.Implicits.namingConvention + + val result = function.setParam(true).execute{queryResult => + val row = queryResult.next() + row.toProductType[ResultTypeOptional] + } + val expected = ResultTypeOptional( + intData = Some(42), + textData = Some("Hello World!"), + timestampData = Some(timestamp), + uuidData = Some(uuid) + ) + assert(result == expected) + } + + test("Product type with Option types is created with no values") { + import za.co.absa.db.mag.naming.implementations.SnakeCaseNaming.Implicits.namingConvention + + val result = function.setParam(false).execute{queryResult => + val row = queryResult.next() + row.toProductType[ResultTypeOptional] + } + val expected = ResultTypeOptional( + None, None, None, None + ) + assert(result == expected) + } + + + test("Product type with no-Option types is create with values") { + import za.co.absa.db.mag.naming.implementations.SnakeCaseNaming.Implicits.namingConvention + + val result = function.setParam(true).execute{queryResult => + val row = queryResult.next() + row.toProductType[ResultType] + } + val expected = ResultType( + intData = 42, + textData = "Hello World!", + timestampData = timestamp, + uuidData = uuid + ) + assert(result == expected) + } + + test("Product type with no-Option types throws NullPointerException exception when no value encountered") { + import za.co.absa.db.mag.naming.implementations.SnakeCaseNaming.Implicits.namingConvention + + function.setParam(false).execute{queryResult => + val row = queryResult.next() + assertThrows[NullPointerException](row.toProductType[ResultType]) + } + } + + test("Product type with wrong names throws NoSuchElementException exception") { + import za.co.absa.db.mag.naming.implementations.SnakeCaseNaming.Implicits.namingConvention + + function.setParam(false).execute{queryResult => + val row = queryResult.next() + assertThrows[NoSuchElementException](row.toProductType[CaseClassOfWrongFields]) + } + } + + test("Product type of tuple is created if correct naming exists") { + implicit val naming: NamingConvention = MapBasedNaming(Map( + "_1" -> "int_data", + "_2" -> "text_data", + "_3" -> "timestamp_data", + "_4" -> "uuid_data" + )) + val result = function.setParam(true).execute{queryResult => + val row = queryResult.next() + row.toProductType[(Int, String, OffsetDateTime, UUID)] + } + val expected = ( + 42, + "Hello World!", + timestamp, + uuid + ) + assert(result == expected) + } +} + +// these classes have to be top level +case class ResultTypeOptional( + intData: Option[Int], + textData: Option[String], + timestampData: Option[OffsetDateTime], + uuidData: Option[UUID] + ) + +case class ResultType( + intData: Int, + textData: String, + timestampData: OffsetDateTime, + uuidData: UUID + ) + +case class CaseClassOfWrongFields( + notInTheRow: String + ) diff --git a/balta/src/test/scala/za/co/absa/db/balta/implicits/QueryResultRowImplicitsUnitTests.scala b/balta/src/test/scala/za/co/absa/db/balta/implicits/QueryResultRowImplicitsUnitTests.scala new file mode 100644 index 0000000..2ac92fe --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/balta/implicits/QueryResultRowImplicitsUnitTests.scala @@ -0,0 +1,40 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.balta.implicits + +import org.scalatest.funsuite.AnyFunSuiteLike + +import scala.reflect.runtime.universe.typeOf + +class QueryResultRowImplicitsUnitTests extends AnyFunSuiteLike { + + test("isOptionType should return true for Option type") { + assert(QueryResultRowImplicits.isOptionType(typeOf[Option[Int]])) + } + + test("isOptionType should return false for non-Option simple type") { + assert(!QueryResultRowImplicits.isOptionType(typeOf[Int])) + } + + test("isOptionType should return false for non-Option complex type") { + assert(!QueryResultRowImplicits.isOptionType(typeOf[this.type])) + } + + test("isOptionType should return false for non-Option container type") { + assert(!QueryResultRowImplicits.isOptionType(typeOf[List[String]])) + } +} diff --git a/balta/src/test/scala/za/co/absa/db/mag/naming/LettersCaseUnitTests.scala b/balta/src/test/scala/za/co/absa/db/mag/naming/LettersCaseUnitTests.scala new file mode 100644 index 0000000..c643fd2 --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/mag/naming/LettersCaseUnitTests.scala @@ -0,0 +1,33 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming + +import org.scalatest.funsuite.AnyFunSuiteLike + +class LettersCaseUnitTests extends AnyFunSuiteLike { + test("AsIs") { + assert(LettersCase.AsIs.convert("Hello World!") == "Hello World!") + } + + test("LowerCase") { + assert(LettersCase.LowerCase.convert("Hello World!") == "hello world!") + } + + test("UpperCase") { + assert(LettersCase.UpperCase.convert("Hello World!") == "HELLO WORLD!") + } +} diff --git a/balta/src/test/scala/za/co/absa/db/mag/naming/implementations/AsIsNamingUnitTests.scala b/balta/src/test/scala/za/co/absa/db/mag/naming/implementations/AsIsNamingUnitTests.scala new file mode 100644 index 0000000..b35b2ad --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/mag/naming/implementations/AsIsNamingUnitTests.scala @@ -0,0 +1,36 @@ +/* + * Copyright 2022 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.matchers.should.Matchers +import za.co.absa.db.mag.naming.LettersCase + +class AsIsNamingUnitTests extends AnyFunSuiteLike with Matchers { + + val asIsNaming = new AsIsNaming(LettersCase.AsIs) + + test("AsIsNaming should return the same string") { + val input = "testString" + val expectedOutput = "testString" + + val output = asIsNaming.stringPerConvention(input) + + output shouldEqual expectedOutput + } + +} diff --git a/balta/src/test/scala/za/co/absa/db/mag/naming/implementations/ExplicitNamingRequiredUnitTests.scala b/balta/src/test/scala/za/co/absa/db/mag/naming/implementations/ExplicitNamingRequiredUnitTests.scala new file mode 100644 index 0000000..e42a5f2 --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/mag/naming/implementations/ExplicitNamingRequiredUnitTests.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2022 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import za.co.absa.db.mag.exceptions.NamingException + +class ExplicitNamingRequiredUnitTests extends AnyWordSpec with Matchers { + private val explicitNamingRequired = new ExplicitNamingRequired() + + "stringPerConvention" should { + "fail" in { + intercept[NamingException] { + explicitNamingRequired.stringPerConvention("") + } + } + } + + "fromClassNamePerConvention" should { + "fail" in { + intercept[NamingException] { + explicitNamingRequired.fromClassNamePerConvention(this) + } + } + } +} diff --git a/balta/src/test/scala/za/co/absa/db/mag/naming/implementations/MapBasedNamingUnitTests.scala b/balta/src/test/scala/za/co/absa/db/mag/naming/implementations/MapBasedNamingUnitTests.scala new file mode 100644 index 0000000..8ee70dc --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/mag/naming/implementations/MapBasedNamingUnitTests.scala @@ -0,0 +1,69 @@ +/* + * Copyright 2023 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import org.scalatest.funsuite.AnyFunSuiteLike +import za.co.absa.db.mag.exceptions.NamingException +import za.co.absa.db.mag.naming.LettersCase.{LowerCase, UpperCase} + +class MapBasedNamingUnitTests extends AnyFunSuiteLike { + private val map = Map( + "Hello" -> "World!", + "Foo" -> "Bar" + ) + + private val mapNamingConventionAsIs = MapBasedNaming(map) + private val mapNamingConventionLowerUpper = MapBasedNaming(map, LowerCase, UpperCase) + private val mapNamingConventionUpperLower = MapBasedNaming(map, UpperCase, LowerCase) + + test("MapBasedNaming with AsIs LetterCase should return the found string") { + + val input = "Hello" + val expectedOutput = "World!" + val output = mapNamingConventionAsIs.stringPerConvention(input) + + assert(output == expectedOutput) + } + + test("MapBasedNaming with altered case should return the found string in defined output case") { + val input1 = "Hello" + val expectedOutput1 = "WORLD!" + val output1 = mapNamingConventionLowerUpper.stringPerConvention(input1) + + assert(output1 == expectedOutput1) + + val input2 = "Foo" + val expectedOutput2 = "bar" + val output2 = mapNamingConventionUpperLower.stringPerConvention(input2) + + assert(output2 == expectedOutput2) + } + + test("MapBaseNaming fails when key is not found") { + val input = "NotInMap" + assertThrows[NamingException] { + mapNamingConventionAsIs.stringPerConvention(input) + } + } + + test("With default LetterCase MapBasedNaming cares about case") { + val input = "hello" + assertThrows[NamingException] { + mapNamingConventionAsIs.stringPerConvention(input) + } + } +} diff --git a/balta/src/test/scala/za/co/absa/db/mag/naming/implementations/SnakeCaseNamingUnitTests.scala b/balta/src/test/scala/za/co/absa/db/mag/naming/implementations/SnakeCaseNamingUnitTests.scala new file mode 100644 index 0000000..fdd555d --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/mag/naming/implementations/SnakeCaseNamingUnitTests.scala @@ -0,0 +1,53 @@ +/* + * Copyright 2022 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import za.co.absa.db.mag.naming.LettersCase.{AsIs, LowerCase, UpperCase} + +class SnakeCaseNamingUnitTests extends AnyWordSpec with Matchers { + private class ThisIsATestClass + private val testInstance = new ThisIsATestClass() + + "stringPerConvention" should { + "handle empty string" in { + val nm = new SnakeCaseNaming(AsIs) + nm.stringPerConvention("") should be("") + } + } + + "fromClassNamePerConvention" should { + "return snake case" when { + "requested as is" in { + val nm = new SnakeCaseNaming(AsIs) + val result = nm.fromClassNamePerConvention(testInstance) + result should be("This_Is_A_Test_Class") + } + "requested as lowercase" in { + val nm = new SnakeCaseNaming(LowerCase) + val result = nm.fromClassNamePerConvention(testInstance) + result should be("this_is_a_test_class") + } + "requested as upper case" in { + val nm = new SnakeCaseNaming(UpperCase) + val result = nm.fromClassNamePerConvention(testInstance) + result should be("THIS_IS_A_TEST_CLASS") + } + } + } +}