diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 256a7e7..f2982b7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,13 +33,42 @@ jobs: name: Scala ${{matrix.scala}} + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: mag_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: - name: Checkout code uses: actions/checkout@v4 + - uses: coursier/cache-action@v5 + - name: Setup Scala uses: olafurpg/setup-scala@v10 with: java-version: "adopt@1.8" - - name: Build and run tests + + - name: Build and run unit tests run: sbt ++${{matrix.scala}} test doc + + - name: Setup database + run: | + psql postgresql://postgres:postgres@localhost:5432/mag_db -f balta/src/test/resources/db/postgres/02_users.ddl + psql postgresql://postgres:postgres@localhost:5432/mag_db -f balta/src/test/resources/db/postgres/03_schema_testing.ddl + psql postgresql://postgres:postgres@localhost:5432/mag_db -f balta/src/test/resources/db/postgres/04_testing.base_types.ddl + 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 + + - name: Build and run integration tests + run: sbt ++${{matrix.scala}} testIT diff --git a/.github/workflows/test_filenames_check.yml b/.github/workflows/test_filenames_check.yml new file mode 100644 index 0000000..318662b --- /dev/null +++ b/.github/workflows/test_filenames_check.yml @@ -0,0 +1,41 @@ +# +# 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. +# + +name: Test Filenames Check + +on: + pull_request: + branches: [ master ] + types: [ opened, synchronize, reopened ] + +jobs: + test_filenames_check: + name: Test Filenames Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Filename Inspector + id: scan-test-files + uses: AbsaOSS/filename-inspector@v0.1.0 + with: + name-patterns: '*UnitTests.*,*IntegrationTests.*' + paths: '**/src/test/scala/**' + report-format: 'console' + excludes: 'balta/src/test/scala/za/co/absa/db/balta/testing/*' + verbose-logging: 'false' + fail-on-violation: 'true' diff --git a/.sbtrc b/.sbtrc new file mode 100644 index 0000000..5f69dcb --- /dev/null +++ b/.sbtrc @@ -0,0 +1,27 @@ +# +# 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. +# + +# Aliases in this file expected usage of test file naming conventions: +# - "UnitTests" suffix for test files and Suites which define unit tests +# - "IntegrationTests" suffix for test files and Suites which define integration tests + +# CPS QA types aliases +# * Unit tests +alias test=; testOnly *UnitTests + +# * Integration tests +alias testIT=; testOnly *IntegrationTests + diff --git a/README.md b/README.md index 200f97a..e7ec7a4 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,23 @@ It's a natural complement to the use of [Fa-Db library](https://github.com/AbsaO Advantages of this approach is that the tests repeateble, 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: + +```bash +sbt testIT +``` + +The 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 + ## How to Release Please see [this file](RELEASE.md) for more details. + +## Known Issues + +### 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 diff --git a/balta/src/main/scala/za/co/absa/db/balta/DBTestSuite.scala b/balta/src/main/scala/za/co/absa/db/balta/DBTestSuite.scala index 15e7eb8..ba310ed 100644 --- a/balta/src/main/scala/za/co/absa/db/balta/DBTestSuite.scala +++ b/balta/src/main/scala/za/co/absa/db/balta/DBTestSuite.scala @@ -146,7 +146,7 @@ abstract class DBTestSuite extends AnyFunSuite { } // private functions - private def readConnectionInfoFromConfig = { + private def readConnectionInfoFromConfig: ConnectionInfo = { val properties = new Properties() properties.load(getClass.getResourceAsStream("/database.properties")) diff --git a/balta/src/main/scala/za/co/absa/db/balta/classes/QueryResult.scala b/balta/src/main/scala/za/co/absa/db/balta/classes/QueryResult.scala index a429a6a..aa3a987 100644 --- a/balta/src/main/scala/za/co/absa/db/balta/classes/QueryResult.scala +++ b/balta/src/main/scala/za/co/absa/db/balta/classes/QueryResult.scala @@ -16,7 +16,9 @@ package za.co.absa.db.balta.classes -import java.sql.ResultSet +import za.co.absa.db.balta.classes.QueryResultRow.{Extractors, FieldNames} + +import java.sql.{ResultSet, ResultSetMetaData, SQLException} /** * This is an iterator over the result of a query. @@ -24,24 +26,34 @@ import java.sql.ResultSet * @param resultSet - the JDBC result of a query */ class QueryResult(resultSet: ResultSet) extends Iterator[QueryResultRow] { - private [this] var resultSetHasNext: Option[Boolean] = Some(resultSet.next()) + val resultSetMetaData: ResultSetMetaData = resultSet.getMetaData + val columnCount: Int = resultSetMetaData.getColumnCount + + private [this] var nextRow: Option[QueryResultRow] = None + + private [this] implicit val fieldNames: FieldNames = QueryResultRow.fieldNamesFromMetadata(resultSetMetaData) + private [this] implicit val extractors: Extractors = QueryResultRow.createExtractors(resultSetMetaData) override def hasNext: Boolean = { - resultSetHasNext.getOrElse { - val result = resultSet.next() - resultSetHasNext = Some(result) - result + if (nextRow.isEmpty) { + try { + if (resultSet.next()) { + nextRow = Some(QueryResultRow(resultSet)) + } + } catch { + case _: SQLException => // Do nothing + } } + nextRow.nonEmpty } override def next(): QueryResultRow = { - if (resultSetHasNext.isEmpty) { - resultSet.next() - new QueryResultRow(resultSet) + if (hasNext) { + val row = nextRow.get + nextRow = None + row } else { - resultSetHasNext = None - new QueryResultRow(resultSet) - + throw new NoSuchElementException("No more rows in the result set") } } } 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 ab474a4..337aceb 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 @@ -16,83 +16,154 @@ package za.co.absa.db.balta.classes -import org.postgresql.util.PGobject -import za.co.absa.db.balta.classes.simple.JsonBString +import QueryResultRow._ -import java.sql.{Date, ResultSet, Time} -import java.time.{Instant, OffsetDateTime} +import java.sql +import java.sql.{Date, ResultSet, ResultSetMetaData, Time, Types} +import java.time.{Instant, LocalDateTime, OffsetDateTime, OffsetTime} 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 resultSet - the JDBC result of a query + * @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 */ -class QueryResultRow private[classes](val resultSet: ResultSet) extends AnyVal { - // this is not stable as resultSet mutates, but good enough for now - private def safe[T](fnc: => T): Option[T] = { - val result = fnc - if (resultSet.wasNull()) { - None - } else { - Some(result) - } - } +class QueryResultRow private[classes](val rowNumber: Int, + private val fields: Vector[Option[Object]], + private val columnNames: FieldNames) { + + def columnCount: Int = fields.length + def columnNumber(columnLabel: String): Int = columnNames(columnLabel.toLowerCase) + + def apply(column: Int): Option[Object] = fields(column - 1) + def apply(columnLabel: String): Option[Object] = apply(columnNumber(columnLabel)) - def getBoolean(columnLabel: String): Option[Boolean] = safe(resultSet.getBoolean(columnLabel)) + def getAs[T](column: Int, transformer: TransformerFnc[T]): Option[T] = apply(column).map(transformer) + def getAs[T](column: Int): Option[T] = apply(column)map(_.asInstanceOf[T]) - def getChar(columnLabel: String): Option[Char] = { - getString(columnLabel) match { + def getAs[T](columnLabel: String, transformer: TransformerFnc[T]): Option[T] = getAs(columnNumber(columnLabel), transformer) + def getAs[T](columnLabel: String): Option[T] = apply(columnNumber(columnLabel)).map(_.asInstanceOf[T]) + + def getBoolean(column: Int): Option[Boolean] = getAs(column: Int, {item: Object => item.asInstanceOf[Boolean]}) + def getBoolean(columnLabel: String): Option[Boolean] = getBoolean(columnNumber(columnLabel)) + + def getChar(column: Int): Option[Char] = { + getString(column) match { case Some(value) => if (value.isEmpty) None else Some(value.charAt(0)) case None => None } } + def getChar(columnLabel: String): Option[Char] = getChar(columnNumber(columnLabel)) - def getString(columnLabel: String): Option[String] = safe(resultSet.getString(columnLabel)) - def getInt(columnLabel: String): Option[Int] = safe(resultSet.getInt(columnLabel)) + def getString(column: Int): Option[String] = getAs(column: Int, {item: Object => item.asInstanceOf[String]}) + def getString(columnLabel: String): Option[String] = getString(columnNumber(columnLabel)) - def getLong(columnLabel: String): Option[Long] = safe(resultSet.getLong(columnLabel)) + def getInt(column: Int): Option[Int] = getAs(column: Int, {item: Object => item.asInstanceOf[Int]}) + def getInt(columnLabel: String): Option[Int] = getInt(columnNumber(columnLabel)) - def getDouble(columnLabel: String): Option[Double] = safe(resultSet.getDouble(columnLabel)) + def getLong(column: Int): Option[Long] = getAs(column: Int, {item: Object => item.asInstanceOf[Long]}) + def getLong(columnLabel: String): Option[Long] = getLong(columnNumber(columnLabel)) - def getFloat(columnLabel: String): Option[Float] = safe(resultSet.getFloat(columnLabel)) + def getDouble(column: Int): Option[Double] = getAs(column: Int, {item: Object => item.asInstanceOf[Double]}) + def getDouble(columnLabel: String): Option[Double] = getDouble(columnNumber(columnLabel)) - def getBigDecimal(columnLabel: String): Option[BigDecimal] = safe(resultSet.getBigDecimal(columnLabel)) + def getFloat(column: Int): Option[Float] = getAs(column: Int, {item: Object => item.asInstanceOf[Float]}) + def getFloat(columnLabel: String): Option[Float] = getFloat(columnNumber(columnLabel)) - def getUUID(columnLabel: String): Option[UUID] = Option(resultSet.getObject(columnLabel).asInstanceOf[UUID]) + def getBigDecimal(column: Int): Option[BigDecimal] = + getAs(column: Int, {item: Object => item.asInstanceOf[java.math.BigDecimal]}) + .map(scala.math.BigDecimal(_)) + def getBigDecimal(columnLabel: String): Option[BigDecimal] = getBigDecimal(columnNumber(columnLabel)) - def getOffsetDateTime(columnLabel: String): Option[OffsetDateTime] = Option(resultSet.getObject(columnLabel, classOf[OffsetDateTime])) + def getTime(column: Int): Option[Time] = getAs(column: Int, {item: Object => item.asInstanceOf[Time]}) + def getTime(columnLabel: String): Option[Time] = getTime(columnNumber(columnLabel)) - def getInstant(columnLabel: String): Option[Instant] = getOffsetDateTime(columnLabel).map(_.toInstant) + def getDate(column: Int): Option[Date] = getAs(column: Int, {item: Object => item.asInstanceOf[Date]}) + def getDate(columnLabel: String): Option[Date] = getDate(columnNumber(columnLabel)) - def getTime(columnLabel: String): Option[Time] = safe(resultSet.getTime(columnLabel)) + def getLocalDateTime(column: Int): Option[LocalDateTime] = getAs(column: Int, {item: Object => item.asInstanceOf[LocalDateTime]}) + def getLocalDateTime(columnLabel: String): Option[LocalDateTime] = getLocalDateTime(columnNumber(columnLabel)) - def getDate(columnLabel: String): Option[Date] = safe(resultSet.getDate(columnLabel)) + def getOffsetDateTime(column: Int): Option[OffsetDateTime] = getAs(column: Int, {item: Object => item.asInstanceOf[OffsetDateTime]}) + def getOffsetDateTime(columnLabel: String): Option[OffsetDateTime] = getOffsetDateTime(columnNumber(columnLabel)) - def getJsonB(columnLabel: String): Option[JsonBString] = { - Option(resultSet.getObject(columnLabel).asInstanceOf[PGobject])map(pgo => JsonBString(pgo.toString)) + def getInstant(column: Int): Option[Instant] = getOffsetDateTime(column).map(_.toInstant) + def getInstant(columnLabel: String): Option[Instant] = getOffsetDateTime(columnLabel).map(_.toInstant) + + def getUUID(column: Int): Option[UUID] = getAs(column: Int, {item: Object => item.asInstanceOf[UUID]}) + def getUUID(columnLabel: String): Option[UUID] = getUUID(columnNumber(columnLabel)) + + def getArray[T](column: Int): Option[Vector[T]] = { + def transformerFnc(obj: Object): Vector[T] = { + obj.asInstanceOf[sql.Array].getArray().asInstanceOf[Array[T]].toVector + } + getAs(column: Int, transformerFnc _) } - def getArray[T](columnLabel: String): Option[Array[T]] = { - val array = resultSet.getArray(columnLabel) - if (resultSet.wasNull()) { - None - } else { - Option(array.getArray.asInstanceOf[Array[T]]) + def getArray[T](columnLabel: String): Option[Vector[T]] = getArray[T](columnNumber(columnLabel)) + + def getArray[T](column: Int, itemTransformerFnc: TransformerFnc[T]): Option[Vector[T]] = { + def transformerFnc(obj: Object): Vector[T] = { + obj + .asInstanceOf[sql.Array] + .getArray() + .asInstanceOf[Array[Object]] + .toVector + .map(itemTransformerFnc) } + + getAs(column: Int, transformerFnc _) } - def getAs[T](columnLabel: String): Option[T] = { - val result = resultSet.getObject(columnLabel) - if (resultSet.wasNull()) { - None - } else { - val resultTyped = result.asInstanceOf[T] - Option(resultTyped) +} + +object QueryResultRow { + + type FieldNames = Map[String, Int] + type TransformerFnc[T] = Object => T + type Extractor = ResultSet => Option[Object] + type Extractors = Vector[Extractor] + + def apply(resultSet: ResultSet)(implicit fieldNames: FieldNames, extractors: Extractors): QueryResultRow = { + val fields = extractors.map(_(resultSet)) + new QueryResultRow(resultSet.getRow, fields, fieldNames) + } + + def fieldNamesFromMetadata(metaData: ResultSetMetaData): FieldNames = { + Range.inclusive(1, metaData.getColumnCount).map(i => metaData.getColumnName(i) -> i).toMap + } + + def createExtractors(metaData: ResultSetMetaData): Extractors = { + def generalExtractor(resultSet: ResultSet, column: Int): Option[Object] = Option(resultSet.getObject(column)) + + def timeTzExtractor(resultSet: ResultSet, column: Int): Option[Object] = Option(resultSet.getObject(column, classOf[OffsetTime])) + + def timestampExtractor(resultSet: ResultSet, column: Int): Option[Object] = Option(resultSet.getObject(column, classOf[LocalDateTime])) + + def timestampTzExtractor(resultSet: ResultSet, column: Int): Option[Object] = Option(resultSet.getObject(column, classOf[OffsetDateTime])) + + def arrayExtractor(resultSet: ResultSet, column: Int): Option[Object] = { + val array: sql.Array = resultSet.getArray(column) + Option(array) } + + def columnTypeName(column: Int): String = metaData.getColumnTypeName(column).toLowerCase() + + Range.inclusive(1, metaData.getColumnCount).map { column => + val extractor: Extractor = metaData.getColumnType(column) match { + case Types.TIME if columnTypeName(column) == "timetz" => timeTzExtractor(_, column) + case Types.TIMESTAMP if columnTypeName(column) == "timestamptz" => timestampTzExtractor(_, column) + case Types.TIMESTAMP => timestampExtractor(_, column) + case Types.ARRAY => arrayExtractor(_, column) + case _ => generalExtractor(_, column) + } + extractor + }.toVector } } diff --git a/balta/src/main/scala/za/co/absa/db/balta/classes/simple/JsonBString.scala b/balta/src/main/scala/za/co/absa/db/balta/classes/simple/JsonBString.scala index 13a8b4a..b2340b8 100644 --- a/balta/src/main/scala/za/co/absa/db/balta/classes/simple/JsonBString.scala +++ b/balta/src/main/scala/za/co/absa/db/balta/classes/simple/JsonBString.scala @@ -16,4 +16,5 @@ package za.co.absa.db.balta.classes.simple +@deprecated("Use SimpleJsonString instead", "0.2.0") case class JsonBString(value: String) extends AnyVal diff --git a/balta/src/main/scala/za/co/absa/db/balta/classes/simple/SimpleJsonString.scala b/balta/src/main/scala/za/co/absa/db/balta/classes/simple/SimpleJsonString.scala new file mode 100644 index 0000000..d712c49 --- /dev/null +++ b/balta/src/main/scala/za/co/absa/db/balta/classes/simple/SimpleJsonString.scala @@ -0,0 +1,25 @@ +/* + * 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.classes.simple + +/** + * A simple class to signal a JSON string is expected. No validation is performed. No extended comparison functionality + * is provided. + * @param value A JSON string. + */ +case class SimpleJsonString(value: String) extends AnyVal + diff --git a/balta/src/main/scala/za/co/absa/db/balta/implicits/Postgres.scala b/balta/src/main/scala/za/co/absa/db/balta/implicits/Postgres.scala new file mode 100644 index 0000000..5b19ab0 --- /dev/null +++ b/balta/src/main/scala/za/co/absa/db/balta/implicits/Postgres.scala @@ -0,0 +1,41 @@ +/* + * 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.postgresql.util.PGobject +import za.co.absa.db.balta.classes.QueryResultRow +import za.co.absa.db.balta.classes.simple.{JsonBString, SimpleJsonString} + +object Postgres { + implicit class PostgresRow(val row: QueryResultRow) extends AnyVal { + private def jsonBStringTransformer(obj: Object): JsonBString = JsonBString(obj.asInstanceOf[PGobject].toString) + private def simpleJsonStringTransformer(obj: Object): SimpleJsonString = SimpleJsonString(obj.asInstanceOf[PGobject].toString) + + @deprecated("Use getSimpleJsonString instead", "0.2.0") + def getJsonB(column: Int): Option[JsonBString] = row.getAs[JsonBString](column: Int, jsonBStringTransformer _) + @deprecated("Use getSimpleJsonString instead", "0.2.0") + def getJsonB(columnLabel: String): Option[JsonBString] = getJsonB(row.columnNumber(columnLabel)) + + def getSimpleJsonString(column: Int): Option[SimpleJsonString] = row.getAs[SimpleJsonString](column: Int, simpleJsonStringTransformer _) + def getSimpleJsonString(columnLabel: String): Option[SimpleJsonString] = getSimpleJsonString(row.columnNumber(columnLabel)) + + def getSJSArray(column: Int): Option[Vector[SimpleJsonString]] = + row.getArray(column: Int, item => SimpleJsonString(item.asInstanceOf[String])) + def getSJSArray(columnLabel: String): Option[Vector[SimpleJsonString]] = getSJSArray(row.columnNumber(columnLabel)) + + } +} diff --git a/balta/src/test/resources/database.properties b/balta/src/test/resources/database.properties new file mode 100644 index 0000000..6927c67 --- /dev/null +++ b/balta/src/test/resources/database.properties @@ -0,0 +1,8 @@ +# jdbc settings +test.jdbc.url=jdbc:postgresql://localhost:5432/mag_db + +test.jdbc.username=mag_owner +test.jdbc.password=changeme + +# debug - Hint: Use `.only` to run one selected test only. Test are not designed for parallel run. +test.persist.db=false diff --git a/balta/src/test/resources/db/postgres/01_db.ddl b/balta/src/test/resources/db/postgres/01_db.ddl new file mode 100644 index 0000000..b3f3aba --- /dev/null +++ b/balta/src/test/resources/db/postgres/01_db.ddl @@ -0,0 +1,6 @@ +CREATE DATABASE mag_db + WITH + OWNER = postgres + ENCODING = 'UTF8' + TABLESPACE = pg_default + CONNECTION LIMIT = -1; diff --git a/balta/src/test/resources/db/postgres/02_users.ddl b/balta/src/test/resources/db/postgres/02_users.ddl new file mode 100644 index 0000000..56d8354 --- /dev/null +++ b/balta/src/test/resources/db/postgres/02_users.ddl @@ -0,0 +1,9 @@ +CREATE ROLE mag_owner WITH + LOGIN + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + INHERIT + NOREPLICATION + CONNECTION LIMIT -1 + PASSWORD 'changeme'; diff --git a/balta/src/test/resources/db/postgres/03_schema_testing.ddl b/balta/src/test/resources/db/postgres/03_schema_testing.ddl new file mode 100644 index 0000000..d01b823 --- /dev/null +++ b/balta/src/test/resources/db/postgres/03_schema_testing.ddl @@ -0,0 +1,2 @@ +CREATE SCHEMA IF NOT EXISTS testing + AUTHORIZATION mag_owner; diff --git a/balta/src/test/resources/db/postgres/04_testing.base_types.ddl b/balta/src/test/resources/db/postgres/04_testing.base_types.ddl new file mode 100644 index 0000000..a78ad5c --- /dev/null +++ b/balta/src/test/resources/db/postgres/04_testing.base_types.ddl @@ -0,0 +1,22 @@ +DROP TABLE IF EXISTS testing.base_types; + +CREATE TABLE IF NOT EXISTS testing.base_types +( + long_type bigint, + boolean_type boolean, + char_type "char", + string_type text COLLATE pg_catalog."default", + int_type integer, + double_type double precision, + float_type real, + bigdecimal_type numeric(25,10), + date_type date, + time_type time without time zone, + timestamp_type timestamp without time zone, + timestamptz_type timestamp with time zone, + uuid_type uuid, + array_int_type integer[] +); + +ALTER TABLE IF EXISTS testing.base_types + OWNER to mag_owner; diff --git a/balta/src/test/resources/db/postgres/05_testing._base_types_data.sql b/balta/src/test/resources/db/postgres/05_testing._base_types_data.sql new file mode 100644 index 0000000..5db1daf --- /dev/null +++ b/balta/src/test/resources/db/postgres/05_testing._base_types_data.sql @@ -0,0 +1,13 @@ +TRUNCATE testing.base_types; + +INSERT INTO testing.base_types( + long_type, boolean_type, char_type, string_type, int_type, double_type, float_type, bigdecimal_type, date_type, + time_type, timestamp_type, timestamptz_type, + uuid_type, array_int_type) +VALUES (1, true, 'a', 'hello world', 257, 3.14, 2.71, 123456789.0123456789, '2022-08-09'::date, + '10:12:15'::time, '2020-06-02 01:00:00'::timestamp without time zone, '2021-04-03 11:00:00 CET'::timestamp with time zone, + '090416f8-7da0-4598-844b-63659334e5b6'::UUID, '{1,2,3}'::INTEGER[]); + +INSERT INTO testing.base_types( + long_type) +VALUES (NULL); diff --git a/balta/src/test/resources/db/postgres/06_testing.pg_types.ddl b/balta/src/test/resources/db/postgres/06_testing.pg_types.ddl new file mode 100644 index 0000000..22e57d0 --- /dev/null +++ b/balta/src/test/resources/db/postgres/06_testing.pg_types.ddl @@ -0,0 +1,11 @@ +CREATE TABLE testing.pg_types +( + id bigint NOT NULL, + json_type json, + jsonb_type jsonb, + array_of_json_type json[], + PRIMARY KEY (id) +); + +ALTER TABLE IF EXISTS testing.pg_types + OWNER to mag_owner; diff --git a/balta/src/test/resources/db/postgres/07_testing_pg_types_data.sql b/balta/src/test/resources/db/postgres/07_testing_pg_types_data.sql new file mode 100644 index 0000000..7dfe29f --- /dev/null +++ b/balta/src/test/resources/db/postgres/07_testing_pg_types_data.sql @@ -0,0 +1,15 @@ +TRUNCATE testing.pg_types; + + +INSERT INTO testing.pg_types( + id, json_type, jsonb_type, array_of_json_type) +VALUES ( + 1, + '{"a": 1, "b": "Hello"}'::JSON, + '{"Hello" : "World"}'::JSONB, + array['{"a": 2, "body": "Hold the line!"}', '{"a": 3, "body": ""}', '{"a": 4}']::json[] + ); + + +INSERT INTO testing.pg_types(id) +VALUES (2); diff --git a/balta/src/test/scala/za/co/absa/db/balta/classes/QueryResultRowIntegrationTests.scala b/balta/src/test/scala/za/co/absa/db/balta/classes/QueryResultRowIntegrationTests.scala new file mode 100644 index 0000000..bace35c --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/balta/classes/QueryResultRowIntegrationTests.scala @@ -0,0 +1,221 @@ +/* + * 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.classes + +import org.scalatest.funsuite.AnyFunSuiteLike +import za.co.absa.db.balta.testing.classes.DBTestingConnection + +import java.sql.{Date, ResultSetMetaData, Time} +import java.text.SimpleDateFormat +import java.time.{Instant, LocalDateTime, OffsetDateTime} +import java.util.UUID + +class QueryResultRowIntegrationTests extends AnyFunSuiteLike with DBTestingConnection{ + private val (tableRows: List[QueryResultRow], metadata: ResultSetMetaData) = DBTable("testing.base_types").all("long_type") { q => + (q.toList, q.resultSetMetaData) + } + + test("fieldNamesFromMetada") { + val result = QueryResultRow.fieldNamesFromMetadata(metadata) + + val expecedResult = Seq( + "long_type", + "boolean_type", + "char_type", + "string_type", + "int_type", + "double_type", + "float_type", + "bigdecimal_type", + "date_type", + "time_type", + "timestamp_type", + "timestamptz_type", + "uuid_type", + "array_int_type") + .zipWithIndex + .map(x => (x._1, x._2 + 1)) + .toMap + assert(result.size == 14) + assert(result == expecedResult) + } + + test("getLong") { + //first row + assert(tableRows.head.getLong(1).contains(1)) + assert(tableRows.head.getLong("long_type").contains(1)) + assert(tableRows.head.getAs[Long](1).contains(1)) + assert(tableRows.head.getAs[Long]("long_type").contains(1)) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getLong(1).isEmpty) + assert(secondRow.getLong("long_type").isEmpty) + } + + test("getBoolean") { + //first row + assert(tableRows.head.getBoolean(2).contains(true)) + assert(tableRows.head.getBoolean("boolean_type").contains(true)) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getBoolean(2).isEmpty) + assert(secondRow.getBoolean("boolean_type").isEmpty) + } + + test("getChar") { + //first row + assert(tableRows.head.getChar(3).contains('a')) + assert(tableRows.head.getChar("char_type").contains('a')) + assert(tableRows.head.getChar(4).contains('h')) + assert(tableRows.head.getChar("string_type").contains('h')) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getChar(3).isEmpty) + assert(secondRow.getChar("char_type").isEmpty) + } + + test("getString") { + //first row + assert(tableRows.head.getString(4).contains("hello world")) + assert(tableRows.head.getString("string_type").contains("hello world")) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getString(2).isEmpty) + assert(secondRow.getString("string_type").isEmpty) + } + + test("getInt") { + //first row + assert(tableRows.head.getInt(5).contains(257)) + assert(tableRows.head.getInt("int_type").contains(257)) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getInt(5).isEmpty) + assert(secondRow.getInt("int_type").isEmpty) + } + + test("getDouble") { + //first row + assert(tableRows.head.getDouble(6).contains(3.14)) + assert(tableRows.head.getDouble("double_type").contains(3.14)) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getDouble(6).isEmpty) + assert(secondRow.getDouble("double_type").isEmpty) + } + + test("getFloat") { + //first row + assert(tableRows.head.getFloat(7).contains(2.71F)) + assert(tableRows.head.getFloat("float_type").contains(2.71F)) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getFloat(7).isEmpty) + assert(secondRow.getFloat("float_type").isEmpty) + } + + test("getBigDecimal") { + //first row + assert(tableRows.head.getBigDecimal(8).contains(BigDecimal("123456789.0123456789"))) + assert(tableRows.head.getBigDecimal("bigdecimal_type").contains(BigDecimal("123456789.0123456789"))) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getBigDecimal(8).isEmpty) + assert(secondRow.getBigDecimal("bigdecimal_type").isEmpty) + } + + test("getDate") { + //first row + val df = new SimpleDateFormat("yyyy-MM-dd"); + val expected = new Date(df.parse("2022-08-09").getTime) + assert(tableRows.head.getDate(9).contains(expected)) + assert(tableRows.head.getDate("date_type").contains(expected)) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getDate(9).isEmpty) + assert(secondRow.getDate("date_type").isEmpty) + } + + test("getTime") { + //first row + val df = new SimpleDateFormat("hh:mm:ss") + val expected = new Time(df.parse("10:12:15").getTime) + assert(tableRows.head.getTime(10).contains(expected)) + assert(tableRows.head.getTime("time_type").contains(expected)) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getTime(10).isEmpty) + assert(secondRow.getTime("time_type").isEmpty) + } + + + test("getLocalDateTime") { + //first row + val timestampString = "2020-06-02T01:00:00" + val expectedLocalDateTime = LocalDateTime.parse(s"$timestampString") + + assert(tableRows.head.getLocalDateTime(11).contains(expectedLocalDateTime)) + assert(tableRows.head.getLocalDateTime("timestamp_type").contains(expectedLocalDateTime)) + + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getLocalDateTime(11).isEmpty) + assert(secondRow.getLocalDateTime("timestamp_type").isEmpty) + } + + test("getOffsetDateTime and getInstant") { + //first row + val expectedOffsetDateTime = OffsetDateTime.parse("2021-04-03T11:00:00+01:00") + val expectedInstant = Instant.parse("2021-04-03T10:00:00.00Z") + + val resultOfColumn = tableRows.head.getOffsetDateTime(12).get + val resultOfColumnLabel = tableRows.head.getOffsetDateTime("timestamptz_type").get + assert(resultOfColumn.isEqual(expectedOffsetDateTime)) + assert(resultOfColumnLabel.isEqual(expectedOffsetDateTime)) + assert(tableRows.head.getInstant(12).contains(expectedInstant)) + assert(tableRows.head.getInstant("timestamptz_type").contains(expectedInstant)) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getOffsetDateTime(12).isEmpty) + assert(secondRow.getOffsetDateTime("timestamptz_type").isEmpty) + assert(secondRow.getInstant(12).isEmpty) + assert(secondRow.getInstant("timestamptz_type").isEmpty) + } + + test("getUUID") { + //first row + val expected = UUID.fromString("090416f8-7da0-4598-844b-63659334e5b6") + assert(tableRows.head.getUUID(13).contains(expected)) + assert(tableRows.head.getUUID("uuid_type").contains(expected)) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getUUID(13).isEmpty) + assert(secondRow.getUUID("uuid_type").isEmpty) + } + + test("getArray[Int]") { + //first row + val expected = Vector(1, 2, 3) + assert(tableRows.head.getArray[Int](14).contains(expected)) + assert(tableRows.head.getArray[Int]("array_int_type").contains(expected)) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getArray[Int](14).isEmpty) + assert(secondRow.getArray[Int]("array_int_type").isEmpty) + } + +} diff --git a/balta/src/test/scala/za/co/absa/db/balta/implicits/PostgresRowIntegrationTests.scala b/balta/src/test/scala/za/co/absa/db/balta/implicits/PostgresRowIntegrationTests.scala new file mode 100644 index 0000000..ad97888 --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/balta/implicits/PostgresRowIntegrationTests.scala @@ -0,0 +1,78 @@ +/* + * 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.classes.{DBTable, QueryResultRow} +import za.co.absa.db.balta.testing.classes.DBTestingConnection + +import java.sql.ResultSetMetaData +import Postgres.PostgresRow +import za.co.absa.db.balta.classes.simple.SimpleJsonString + +class PostgresRowIntegrationTests extends AnyFunSuiteLike with DBTestingConnection{ + private val (tableRows: List[QueryResultRow], metadata: ResultSetMetaData) = DBTable("testing.pg_types").all("id") { q => + (q.toList, q.resultSetMetaData) + } + + test("fieldNamesFromMetada") { + val result = QueryResultRow.fieldNamesFromMetadata(metadata) + + val expectedResult = Seq( + "id", + "json_type", + "jsonb_type", + "array_of_json_type" + ) + .zipWithIndex + .map(x => (x._1, x._2 + 1)) + .toMap + assert(result.size == 4) + assert(result == expectedResult) + } + + test("getSimpleJson") { + //first row + val expectedJson = SimpleJsonString("""{"a": 1, "b": "Hello"}""") + val expectedJsonB = SimpleJsonString("""{"Hello": "World"}""") + assert(tableRows.head.getSimpleJsonString(2).contains(expectedJson)) + assert(tableRows.head.getSimpleJsonString("json_type").contains(expectedJson)) + assert(tableRows.head.getSimpleJsonString(3).contains(expectedJsonB)) + assert(tableRows.head.getSimpleJsonString("jsonb_type").contains(expectedJsonB)) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getSimpleJsonString(2).isEmpty) + assert(secondRow.getSimpleJsonString("json_type").isEmpty) + assert(secondRow.getSimpleJsonString(3).isEmpty) + assert(secondRow.getSimpleJsonString("jsonb_type").isEmpty) + } + + test("getArrayJson") { + //first row + val expected = Vector( + SimpleJsonString("""{"a": 2, "body": "Hold the line!"}"""), + SimpleJsonString("""{"a": 3, "body": ""}"""), + SimpleJsonString("""{"a": 4}""") + ) + assert(tableRows.head.getSJSArray(4).get == expected) + assert(tableRows.head.getSJSArray("array_of_json_type").contains(expected)) + //second row + val secondRow = tableRows.tail.head + assert(secondRow.getSJSArray(4).isEmpty) + assert(secondRow.getSJSArray("array_of_json_type").isEmpty) + } +} diff --git a/balta/src/test/scala/za/co/absa/db/balta/testing/classes/DBTestingConnection.scala b/balta/src/test/scala/za/co/absa/db/balta/testing/classes/DBTestingConnection.scala new file mode 100644 index 0000000..f604639 --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/balta/testing/classes/DBTestingConnection.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.testing.classes + +import za.co.absa.db.balta.classes.DBConnection +import za.co.absa.db.balta.classes.simple.ConnectionInfo + +import java.util.Properties + +trait DBTestingConnection { + lazy implicit val connection: DBConnection = DBConnection(connectionInfo) + + protected lazy val connectionInfo: ConnectionInfo = readConnectionInfoFromConfig + + private def readConnectionInfoFromConfig: ConnectionInfo = { + val properties = new Properties() + properties.load(getClass.getResourceAsStream("/database.properties")) + + ConnectionInfo( + dbUrl = properties.getProperty("test.jdbc.url"), + username = properties.getProperty("test.jdbc.username"), + password = properties.getProperty("test.jdbc.password"), + persistData = properties.getProperty("test.persist.db", "false").toBoolean + ) + } +} diff --git a/build.sbt b/build.sbt index 52093c3..7e9bcfe 100644 --- a/build.sbt +++ b/build.sbt @@ -22,6 +22,8 @@ lazy val scala213 = "2.13.11" lazy val supportedScalaVersions: Seq[String] = Seq(scala211, scala212 , scala213) +name := "balta" + ThisBuild / scalaVersion := scala212 ThisBuild / versionScheme := Some("early-semver")