Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#13: QueryResultRow conversion for Product types #36

Merged
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .github/workflows/jacoco_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .sbtrc
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ alias test=; testOnly *UnitTests
# * Integration tests
alias testIT=; testOnly *IntegrationTests

# Run all tests
alias testAll=; testOnly *
63 changes: 59 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -35,7 +36,10 @@ class QueryResultRow private[classes](val rowNumber: Int,
private val columnNames: FieldNames) {

def columnCount: Int = fields.length
def columnNumber(columnLabel: String): Int = columnNames(columnLabel.toLowerCase)
def columnNumber(columnLabel: String): Int = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why Label terminology?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original terminology from JDBC

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but it's column name right?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I read here that it's this: https://bugs.mysql.com/bug.php?id=35610

so alias or column name. Not sure if in our case it's always only column name?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look at the ResultSet class methods.

Copy link
Collaborator

@lsulak lsulak Oct 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I still don't fully know the answer if you think deeper into it (i.e. whether both can be applied to our case or only column name) :D

https://docs.oracle.com/javase/7/docs/api/java/sql/ResultSet.html

E.g.

  • columnLabel - the label for the column specified with the SQL AS clause. If the SQL AS clause was not specified, then the label is the name of the column
  • Column names used as input to getter methods are case insensitive. When a getter method is called with a column name and several columns have the same name, the value of the first matching column will be returned. The column name option is designed to be used when column names are used in the SQL query that generated the result set. For columns that are NOT explicitly named in the query, it is best to use column numbers. If column names are used, the programmer should take care to guarantee that they uniquely refer to the intended columns, which can be assured with the SQL AS clause.

Okay, we can follow it. But maybe at least document it super briefly? That this terminology is coming from X with some link perhaps etc

Copy link
Collaborator

@lsulak lsulak Oct 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Basically, if we are using an underlying technology A that is using special terminology B for something that uses X and Y, and X is known thing - in our case column name - and if we are not using Y, then it makes sense not to follow A's way by using B, just use X; special-ish undocumented terms are never a good thing in my opinion)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please suggest the documentation text. I am not sure I follow.

val actualLabel = columnLabel.toLowerCase
columnNames.getOrThrow(actualLabel, new NoSuchElementException(s"Column '$actualLabel' not found"))
}

/**
* Extracts a value from the row by column number.
Expand Down Expand Up @@ -131,7 +135,6 @@ class QueryResultRow private[classes](val rowNumber: Int,

getAs(column: Int, transformerFnc _)
}

}

object QueryResultRow {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}

}
Original file line number Diff line number Diff line change
@@ -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)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, deciding how to pass in the actual value to the constructor.

value
} else {
value.getOrThrow(new NullPointerException(s"Column '$columnLabel' is null"))
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
55 changes: 55 additions & 0 deletions balta/src/main/scala/za/co/absa/db/mag/naming/LettersCase.scala
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading