diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 37e28ed..c10fd04 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -58,17 +58,38 @@ Testing - Must write all failing tests first (red), then implement until all pass (green). - Must cover all distinct combinations; each test must state its scenario in the ScalaDoc. - Must update `SPEC.md` after all tests pass with the confirmed test case table. +- When a unit test adds value — write one: + - Method has any own logic: branching (`if`/`match`), exception handling or swallowing, non-trivial transformation, config/resource read, or reflection. +- When to add to `jmf-rules.txt` instead of writing a unit test: + - Body is a single call with no own logic: forwards to another overload, calls its non-deprecated replacement, returns a field, or wraps a constructor with no transformation. + - Litmus: "Does this method have any logic of its own?" — No → JMF. +- Global rule collision check (CRITICAL): + - When adding any new method, check if it matches a pattern in the `# GLOBAL RULES` section of `jmf-rules.txt` (line ~22+). + - If method name matches a global rule AND the method has domain logic: immediately create an INCLUDE rescue rule (`+FQCN#method(*)`) in the `# INCLUDE RULES` section of `jmf-rules.txt`. + - High-risk method names: refer to `jmf-rules.txt` GLOBAL RULES section for complete list; most common collisions: `apply()`, `toString()`, `equals()`, `copy()`, `name()`, `groups()`, `optionalAttributes()`. + - Rationale: Broad global rules designed for compiler-generated boilerplate can silently hide coverage for domain methods. INCLUDE rules rescue specific methods from broad exclusions. + - Example: If adding `def apply(id: String): Record`, add `+*Record$#apply(*) id:keep-record-factory` to rescue from the `*$*#apply(*)` global rule in GLOBAL RULES section. +- Review rule — JMF drift check: when modifying a method that appears in `jmf-rules.txt`, verify its body still qualifies; if own logic has been added, remove the JMF rule and write a unit test instead. Tooling - Must format with scalafmt (`.scalafmt.conf`); lint with scalastyle (`scalastyle-config.xml`) or wartremover as configured. - Compiler warnings treated as errors where configured; coverage ≥ 80% via sbt-jacoco (excluding JMF-filtered methods in `jmf-rules.txt`). Coverage filtering (JMF) -- A method qualifies for a JMF filter rule if it meets at least one criterion: - - No added value: trivial delegate, one-liner factory, or pure field accessor — a test adds no assurance beyond testing the delegated method. - - Not coverable without integration tests: requires a DB/external system; same path already exercised by integration tests. -- Qualifying patterns: deprecated single-call delegates; `columnLabel: String` overloads that only call `columnNumber(label)` + Int-based overload; one-liner factory wrappers; implicit single-field accessors. +- A method qualifies when its body is a single call with no own logic (see Testing section for the full decision rule and qualifying patterns). - Must not add JMF rules for methods with branching logic, error handling, or non-trivial transformations. +- Rules file: `jmf-rules.txt`; one rule per line, `#` comments and blank lines ignored. +- Rule syntax: `#() [FLAGS] [PREDICATES]` + - FQCN_glob: dot-form class pattern (`*`, `*.model.*`, `com.example.*`; `$` for inner/companion classes). + - method_glob: glob on method name (`copy`, `get*`, `$anonfun$*`, `*_$eq`). + - descriptor_glob: JVM descriptor `(args)ret`; omitting or `(*)` means any args/any return. Must use JVM internal format: `(I)*` not `(int)`, `(Z)*` not `(boolean)`, `(Ljava/lang/String;)*` not `(java.lang.String)`. Non-matching descriptors are silently ignored — no warning is emitted. + - FLAGS (space/comma separated): `public`, `protected`, `private`, `synthetic`, `bridge`, `static`, `abstract`. + - PREDICATES: `ret:` (return type), `id:` (log label), `name-contains:`, `name-starts:`, `name-ends:`. +- Every rule Must include an `id:` label for traceability. +- Adoption order: start with CONSERVATIVE (case-class boilerplate, compiler synthetics), then STANDARD; use AGGRESSIVE only for DTO/auto-generated packages. +- Prefer narrow package scopes; prefer `synthetic`/`bridge` flags for compiler artifacts over broad wildcards. +- Must prefix project-specific FQCN globs with `*` so they match the full qualified class name (e.g. `*QueryResult#noMore()`, not `QueryResult#noMore()`). +- When adding a project-specific rule, add it under the `# PROJECT RULES` section with a comment explaining why the method qualifies. Quality gates - sbt "testOnly *UnitTests" # unit tests, no DB needed @@ -83,10 +104,13 @@ Common pitfalls to avoid - Logging: never pre-build log strings; always pass args lazily (call-by-name). - Cleanup: remove unused imports/variables (scalac `-Ywarn-unused`). - Stability: avoid changing externally-visible method signatures, SQL output, or parameter binding order. +- JMF silent failures: rules with non-matching FQCN or descriptor globs are silently ignored; always verify new rules take effect by comparing JaCoCo method-level output before and after. +- Scala reflection: case classes used with `currentMirror.reflectClass` in tests must be top-level (package scope), not inner classes — Scala reflection cannot handle inner class mirrors. Learned rules - Must not change `QueryResultRow` getter return types or `stringPerConvention` output for existing scenarios. - Must not change externally-visible SQL generation patterns without updating dependent tests. +- Scala 2.12 compiler artifacts that generate coverable bytecode: `$anonfun$` lambdas (ACC_SYNTHETIC), `$deserializeLambda$` (private static, not synthetic), Function1 mixin forwarders (`andThen`/`compose`), value class `$extension` methods, `$default$` parameter methods, Iterator trait mixin (~80+ forwarders). These are JMF candidates, not unit-test candidates. Repo additions - Project name: balta 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 dc6633f..3dbaa47 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 @@ -45,10 +45,12 @@ class QueryResultRow private[classes](val rowNumber: Int, /** * Extracts a value from the row by column number. - * @param column - the number of the column, 1 based + * @param column - the 1-based column number (JDBC convention); note: prior to this fix the parameter was + * incorrectly treated as 0-based (direct vector index), causing column 1 to silently return + * the value of the second column * @return - the value stored in the column, type `Any` is for warningless comparison with any type */ - def apply(column: Int): Option[Any] = getObject(column - 1) + def apply(column: Int): Option[Any] = getObject(column) /** * Extracts a value from the row by column name. * @param columnLabel - the name of the column diff --git a/balta/src/test/resources/database-persist.properties b/balta/src/test/resources/database-persist.properties new file mode 100644 index 0000000..1cf8437 --- /dev/null +++ b/balta/src/test/resources/database-persist.properties @@ -0,0 +1,7 @@ +# jdbc settings — test fixture with persist=true +test.jdbc.url=jdbc:postgresql://localhost:5432/mag_db + +test.jdbc.username=mag_owner +test.jdbc.password=changeme + +test.persist.db=true diff --git a/balta/src/test/scala/za/co/absa/db/balta/DBTestSuiteUnitTests.scala b/balta/src/test/scala/za/co/absa/db/balta/DBTestSuiteUnitTests.scala index 93f2a1e..019f175 100644 --- a/balta/src/test/scala/za/co/absa/db/balta/DBTestSuiteUnitTests.scala +++ b/balta/src/test/scala/za/co/absa/db/balta/DBTestSuiteUnitTests.scala @@ -17,8 +17,14 @@ package za.co.absa.db.balta import org.scalatest.funsuite.AnyFunSuiteLike +import za.co.absa.db.balta.classes.DBFunction.DBFunctionWithPositionedParamsOnly +import za.co.absa.db.balta.classes.DBTable +import za.co.absa.db.balta.classes.inner.ConnectionInfo +import za.co.absa.db.balta.classes.inner.Params.{NamedParams, OrderedParams} +import za.co.absa.db.balta.typeclasses.QueryParamType class DBTestSuiteUnitTests extends AnyFunSuiteLike { + test("connectionInfoFromResourceConfig reads local resource file correctly") { val connectionInfo = DBTestSuite.connectionInfoFromResourceConfig("/database.properties") assert(connectionInfo.dbUrl == "jdbc:postgresql://localhost:5432/mag_db") @@ -26,4 +32,82 @@ class DBTestSuiteUnitTests extends AnyFunSuiteLike { assert(connectionInfo.password == "changeme") assert(!connectionInfo.persistData) } + + test("connectionInfoFromResourceConfig reads persistData true when configured") { + val connectionInfo = DBTestSuite.connectionInfoFromResourceConfig("/database-persist.properties") + assert(connectionInfo.persistData) + } + + test("connectionInfo with persistDataOverride=Some(true) overrides config value") { + val suite = new ConcreteSuite(Some(true)) + val info = suite.exposedConnectionInfo + assert(info.persistData) + } + + test("connectionInfo with persistDataOverride=Some(false) overrides config value") { + val suite = new ConcreteSuite(Some(false)) + val info = suite.exposedConnectionInfo + assert(!info.persistData) + } + + test("connectionInfo with persistDataOverride=None uses config value") { + val suite = new ConcreteSuite(None) + val info = suite.exposedConnectionInfo + assert(!info.persistData) + } + + test("table helper creates DBTable with provided name") { + val suite = new ConcreteSuite(None) + val table = suite.exposedTable("my_table") + assert(table == DBTable("my_table")) + } + + test("function helper creates DBFunction with provided name") { + val suite = new ConcreteSuite(None) + val fn = suite.exposedFunction("my_fn") + assert(fn.isInstanceOf[DBFunctionWithPositionedParamsOnly]) + assert(fn.functionName == "my_fn") + } + + test("named add helper delegates to Params.add") { + val suite = new ConcreteSuite(None) + val params: NamedParams = suite.exposedAdd("id", 7) + assert(params.size == 1) + assert(params("id").sqlEntry == "?") + } + + test("named addNull helper delegates to Params.addNull") { + val suite = new ConcreteSuite(None) + val params: NamedParams = suite.exposedAddNull("missing") + assert(params.size == 1) + assert(params("missing").sqlEntry == "NULL") + assert(params("missing").equalityOperator == "IS") + } + + test("ordered add helper delegates to Params.add") { + val suite = new ConcreteSuite(None) + val params: OrderedParams = suite.exposedAdd(1) + assert(params.size == 1) + assert(params.values.head.sqlEntry == "?") + } + + test("ordered addNull helper delegates to Params.addNull") { + val suite = new ConcreteSuite(None) + val params: OrderedParams = suite.exposedAddNull[QueryParamType.NULL.type]() + assert(params.size == 1) + assert(params.values.head.sqlEntry == "NULL") + assert(params.values.head.equalityOperator == "IS") + } + + private class ConcreteSuite(override val persistDataOverride: Option[Boolean]) + extends DBTestSuite(persistDataOverride) { + def exposedConnectionInfo: ConnectionInfo = connectionInfo + def exposedTable(tableName: String): DBTable = table(tableName) + def exposedFunction(functionName: String): DBFunctionWithPositionedParamsOnly = function(functionName) + def exposedAdd[T: QueryParamType](name: String, value: T): NamedParams = add(name, value) + def exposedAddNull(name: String): NamedParams = addNull(name) + def exposedAdd[T: QueryParamType](value: T): OrderedParams = add(value) + def exposedAddNull[T: QueryParamType](): OrderedParams = addNull[T]() + } } + diff --git a/balta/src/test/scala/za/co/absa/db/balta/classes/DBFunctionUnitTests.scala b/balta/src/test/scala/za/co/absa/db/balta/classes/DBFunctionUnitTests.scala new file mode 100644 index 0000000..354d252 --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/balta/classes/DBFunctionUnitTests.scala @@ -0,0 +1,87 @@ +/* + * 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.classes.DBFunction.{DBFunctionWithNamedParamsToo, DBFunctionWithPositionedParamsOnly} + +class DBFunctionUnitTests extends AnyFunSuiteLike { + + test("DBFunction.apply creates a positioned-params instance with no params") { + val fn = DBFunction("my_schema.my_function") + assert(fn.isInstanceOf[DBFunctionWithPositionedParamsOnly]) + assert(fn.params.isEmpty) + assert(fn.functionName == "my_schema.my_function") + } + + test("setParam by position auto-increments the key") { + val fn = DBFunction("fn") + .setParam(10) + .setParam("hello") + .setParam(true) + assert(fn.params.size == 3) + assert(fn.params.keys.toList == List(Left(1), Left(2), Left(3))) + } + + test("setParam by position returns DBFunctionWithPositionedParamsOnly") { + val fn = DBFunction("fn").setParam(42) + assert(fn.isInstanceOf[DBFunctionWithPositionedParamsOnly]) + } + + test("setParam by name creates a named-params instance") { + val fn = DBFunction("fn").setParam("p_name", "value") + assert(fn.isInstanceOf[DBFunctionWithNamedParamsToo]) + assert(fn.params.size == 1) + assert(fn.params.keys.head == Right("p_name")) + } + + test("mixing positioned and named params preserves order") { + val fn = DBFunction("fn") + .setParam(1) + .setParam(2) + .setParam("named", "val") + assert(fn.params.size == 3) + assert(fn.params.keys.toList == List(Left(1), Left(2), Right("named"))) + } + + test("clear resets params to empty") { + val fn = DBFunction("fn").setParam(1).setParam(2).clear() + assert(fn.params.isEmpty) + assert(fn.functionName == "fn") + } + + test("clear returns DBFunctionWithPositionedParamsOnly") { + val fn = DBFunction("fn").setParam("p", "v").clear() + assert(fn.isInstanceOf[DBFunctionWithPositionedParamsOnly]) + } + + test("setParamNull by name adds NULL param with named key") { + val fn = DBFunction("fn").setParamNull("p_null") + assert(fn.params.size == 1) + assert(fn.params.keys.head == Right("p_null")) + assert(fn.params.values.head.sqlEntry == "NULL") + assert(fn.params.values.head.equalityOperator == "IS") + } + + test("setParamNull by position appends NULL as next index") { + val fn = DBFunction("fn").setParam(10).setParamNull() + assert(fn.params.size == 2) + assert(fn.params.keys.toList == List(Left(1), Left(2))) + assert(fn.params.values.toList.last.sqlEntry == "NULL") + assert(fn.params.values.toList.last.equalityOperator == "IS") + } +} diff --git a/balta/src/test/scala/za/co/absa/db/balta/classes/QueryResultRowUnitTests.scala b/balta/src/test/scala/za/co/absa/db/balta/classes/QueryResultRowUnitTests.scala new file mode 100644 index 0000000..ad31dcc --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/balta/classes/QueryResultRowUnitTests.scala @@ -0,0 +1,343 @@ +/* + * 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 java.sql.{ResultSetMetaData, Types} +import java.time.{OffsetDateTime, OffsetTime} + +import org.scalatest.funsuite.AnyFunSuiteLike + +import za.co.absa.db.balta.MockResultSets +import za.co.absa.db.balta.classes.QueryResultRow.FieldNames +import za.co.absa.db.balta.implicits.QueryResultRowImplicits.ProductTypeConvertor +import za.co.absa.db.mag.naming.LettersCase.AsIs +import za.co.absa.db.mag.naming.implementations.AsIsNaming + +case class SimpleRecord(name: String, age: Int) +case class OptionalRecord(label: String, value: Option[Int]) + +case class MultiCtorRecord(x: Int, y: String) { + def this(x: Int) = this(x, "default") +} + +class QueryResultRowUnitTests extends AnyFunSuiteLike { + + private implicit val naming: AsIsNaming = new AsIsNaming(AsIs) + + private val sampleFields: Vector[Option[Object]] = Vector( + Some("hello".asInstanceOf[Object]), + Some(java.lang.Integer.valueOf(42).asInstanceOf[Object]), + None, + Some("".asInstanceOf[Object]) + ) + + private val sampleColumnLabels: FieldNames = Map( + "name" -> 1, + "age" -> 2, + "nullable" -> 3, + "empty" -> 4 + ) + + private def mkRow(fields: Vector[Option[Object]] = sampleFields, + labels: FieldNames = sampleColumnLabels, + rowNum: Int = 1): QueryResultRow = { + new QueryResultRow(rowNum, fields, labels) + } + + test("columnNumber returns correct index for existing column") { + val row = mkRow() + assert(row.columnNumber("name") == 1) + assert(row.columnNumber("age") == 2) + } + + test("apply(Int) returns correct value by 1-based column index") { + val row = mkRow() + assert(row(1).contains("hello")) + assert(row(2).contains(java.lang.Integer.valueOf(42))) + } + + test("apply(Int) returns None for null column") { + val row = mkRow() + assert(row(3).isEmpty) + } + + test("apply(String) returns correct value by column label") { + val row = mkRow() + assert(row("name").contains("hello")) + assert(row("age").contains(java.lang.Integer.valueOf(42))) + } + + test("apply(String) returns None for null column") { + val row = mkRow() + assert(row("nullable").isEmpty) + } + + test("columnNumber is case-insensitive") { + val row = mkRow() + assert(row.columnNumber("NAME") == 1) + assert(row.columnNumber("Age") == 2) + } + + test("columnNumber throws NoSuchElementException for missing column") { + val row = mkRow() + val ex = intercept[NoSuchElementException] { + row.columnNumber("nonexistent") + } + assert(ex.getMessage.contains("nonexistent")) + } + + test("getChar returns first character of a non-empty string") { + val row = mkRow() + assert(row.getChar(1).contains('h')) + } + + test("getChar returns None for empty string") { + val row = mkRow() + assert(row.getChar(4).isEmpty) + } + + test("getChar returns None for null column") { + val row = mkRow() + assert(row.getChar(3).isEmpty) + } + + test("getAs with transformer applies the function") { + val row = mkRow() + val result = row.getAs[Int](2, { obj: Object => obj.asInstanceOf[java.lang.Integer].intValue() }) + assert(result.contains(42)) + } + + test("getAs with transformer returns None for null column") { + val row = mkRow() + val result = row.getAs[String](3, { obj: Object => obj.toString }) + assert(result.isEmpty) + } + + test("getObject returns Some for non-null column") { + val row = mkRow() + assert(row.getObject(1).isDefined) + } + + test("getObject returns None for null column") { + val row = mkRow() + assert(row.getObject(3).isEmpty) + } + + test("fieldNamesFromMetadata builds correct map") { + val metaData = new StubMetaData( + List(("id", Types.INTEGER, "int4"), ("name", Types.VARCHAR, "varchar")) + ) + val result = QueryResultRow.fieldNamesFromMetadata(metaData) + assert(result == Map("id" -> 1, "name" -> 2)) + } + + test("fieldNamesFromMetadata returns empty map for zero columns") { + val metaData = new StubMetaData(Nil) + val result = QueryResultRow.fieldNamesFromMetadata(metaData) + assert(result.isEmpty) + } + + test("createExtractors returns correct number of extractors") { + val metaData = new StubMetaData( + List(("a", Types.VARCHAR, "varchar"), ("b", Types.INTEGER, "int4"), ("c", Types.TIMESTAMP, "timestamp")) + ) + val extractors = QueryResultRow.createExtractors(metaData) + assert(extractors.size == 3) + } + + test("createExtractors dispatches timestamptz to OffsetDateTime extractor") { + val expected = OffsetDateTime.parse("2024-01-15T10:30:00+02:00") + val metaData = new StubMetaData(List(("ts", Types.TIMESTAMP, "timestamptz"))) + val rs = new MockResultSets.MockResultSet(1) { + override def getObject[T](columnIndex: Int, `type`: Class[T]): T = expected.asInstanceOf[T] + } + val extractors = QueryResultRow.createExtractors(metaData) + assert(extractors.size == 1) + assert(extractors.head(rs).contains(expected)) + } + + test("createExtractors dispatches timetz to OffsetTime extractor") { + val expected = OffsetTime.parse("10:30:00+02:00") + val metaData = new StubMetaData(List(("t", Types.TIME, "timetz"))) + val rs = new MockResultSets.MockResultSet(1) { + override def getObject[T](columnIndex: Int, `type`: Class[T]): T = expected.asInstanceOf[T] + } + val extractors = QueryResultRow.createExtractors(metaData) + assert(extractors.size == 1) + assert(extractors.head(rs).contains(expected)) + } + + test("createExtractors dispatches ARRAY type") { + val arr: Array[Object] = Array(java.lang.Integer.valueOf(1)) + val sqlArr = new StubSqlArray(arr) + val metaData = new StubMetaData(List(("arr", Types.ARRAY, "_int4"))) + val rs = new MockResultSets.MockResultSet(1) { + override def getArray(columnIndex: Int): java.sql.Array = sqlArr + } + val extractors = QueryResultRow.createExtractors(metaData) + assert(extractors.size == 1) + assert(extractors.head(rs).contains(sqlArr)) + } + + test("getArray returns Vector from sql.Array column") { + val arr: Array[Object] = Array( + java.lang.Integer.valueOf(1), + java.lang.Integer.valueOf(2), + java.lang.Integer.valueOf(3) + ) + val sqlArray = new StubSqlArray(arr) + val fields: Vector[Option[Object]] = Vector(Some(sqlArray.asInstanceOf[Object])) + val labels: FieldNames = Map("nums" -> 1) + val row = new QueryResultRow(1, fields, labels) + val result = row.getArray[Int](1) + assert(result.contains(Vector(1, 2, 3))) + } + + test("getArray returns None for null column") { + val fields: Vector[Option[Object]] = Vector(None) + val labels: FieldNames = Map("nums" -> 1) + val row = new QueryResultRow(1, fields, labels) + val result = row.getArray[Int](1) + assert(result.isEmpty) + } + + test("getArray with transformer applies itemTransformerFnc") { + val arr: Array[Object] = Array( + java.lang.Integer.valueOf(10), + java.lang.Integer.valueOf(20) + ) + val sqlArray = new StubSqlArray(arr) + val fields: Vector[Option[Object]] = Vector(Some(sqlArray.asInstanceOf[Object])) + val labels: FieldNames = Map("nums" -> 1) + val row = new QueryResultRow(1, fields, labels) + val result = row.getArray[String](1, { obj: Object => obj.toString }) + assert(result.contains(Vector("10", "20"))) + } + + test("getArray by column label delegates to int overload") { + val arr: Array[Object] = Array(java.lang.Integer.valueOf(5)) + val sqlArray = new StubSqlArray(arr) + val fields: Vector[Option[Object]] = Vector(Some(sqlArray.asInstanceOf[Object])) + val labels: FieldNames = Map("data" -> 1) + val row = new QueryResultRow(1, fields, labels) + val result = row.getArray[Int]("data") + assert(result.contains(Vector(5))) + } + + test("toProductType converts a row to a case class") { + val fields: Vector[Option[Object]] = Vector( + Some("Alice".asInstanceOf[Object]), + Some(java.lang.Integer.valueOf(30).asInstanceOf[Object]) + ) + val labels: FieldNames = Map("name" -> 1, "age" -> 2) + val row = new QueryResultRow(1, fields, labels) + val result = row.toProductType[SimpleRecord] + assert(result == SimpleRecord("Alice", 30)) + } + + test("toProductType handles Option field with value") { + val fields: Vector[Option[Object]] = Vector( + Some("test".asInstanceOf[Object]), + Some(java.lang.Integer.valueOf(42).asInstanceOf[Object]) + ) + val labels: FieldNames = Map("label" -> 1, "value" -> 2) + val row = new QueryResultRow(1, fields, labels) + val result = row.toProductType[OptionalRecord] + assert(result == OptionalRecord("test", Some(42))) + } + + test("toProductType handles Option field with null") { + val fields: Vector[Option[Object]] = Vector( + Some("test".asInstanceOf[Object]), + None + ) + val labels: FieldNames = Map("label" -> 1, "value" -> 2) + val row = new QueryResultRow(1, fields, labels) + val result = row.toProductType[OptionalRecord] + assert(result == OptionalRecord("test", None)) + } + + test("toProductType throws NullPointerException for null non-Option field") { + val fields: Vector[Option[Object]] = Vector( + None, + Some(java.lang.Integer.valueOf(1).asInstanceOf[Object]) + ) + val labels: FieldNames = Map("name" -> 1, "age" -> 2) + val row = new QueryResultRow(1, fields, labels) + val ex = intercept[NullPointerException] { + row.toProductType[SimpleRecord] + } + assert(ex.getMessage.contains("name")) + } + + test("toProductType works with case class having auxiliary constructor") { + val fields: Vector[Option[Object]] = Vector( + Some(java.lang.Integer.valueOf(99).asInstanceOf[Object]), + Some("alt".asInstanceOf[Object]) + ) + val labels: FieldNames = Map("x" -> 1, "y" -> 2) + val row = new QueryResultRow(1, fields, labels) + val result = row.toProductType[MultiCtorRecord] + assert(result == MultiCtorRecord(99, "alt")) + } + + /** + * Stub ResultSetMetaData for unit testing. + */ + private class StubMetaData(columns: List[(String, Int, String)]) extends ResultSetMetaData { + override def getColumnCount: Int = columns.size + override def getColumnName(column: Int): String = columns(column - 1)._1 + override def getColumnType(column: Int): Int = columns(column - 1)._2 + override def getColumnTypeName(column: Int): String = columns(column - 1)._3 + override def getColumnLabel(column: Int): String = getColumnName(column) + override def isAutoIncrement(column: Int): Boolean = false + override def isCaseSensitive(column: Int): Boolean = false + override def isSearchable(column: Int): Boolean = false + override def isCurrency(column: Int): Boolean = false + override def isNullable(column: Int): Int = ResultSetMetaData.columnNullable + override def isSigned(column: Int): Boolean = false + override def getColumnDisplaySize(column: Int): Int = 0 + override def getSchemaName(column: Int): String = "" + override def getPrecision(column: Int): Int = 0 + override def getScale(column: Int): Int = 0 + override def getTableName(column: Int): String = "" + override def getCatalogName(column: Int): String = "" + override def isReadOnly(column: Int): Boolean = false + override def isWritable(column: Int): Boolean = false + override def isDefinitelyWritable(column: Int): Boolean = false + override def getColumnClassName(column: Int): String = "java.lang.Object" + override def unwrap[T](iface: Class[T]): T = throw new UnsupportedOperationException + override def isWrapperFor(iface: Class[_]): Boolean = false + } + + /** + * Stub java.sql.Array for unit testing getArray methods. + */ + private class StubSqlArray(data: Array[Object]) extends java.sql.Array { + override def getBaseTypeName: String = "int4" + override def getBaseType: Int = Types.INTEGER + override def getArray: AnyRef = data + override def getArray(map: java.util.Map[String, Class[_]]): AnyRef = data + override def getArray(index: Long, count: Int): AnyRef = data + override def getArray(index: Long, count: Int, map: java.util.Map[String, Class[_]]): AnyRef = data + override def getResultSet: java.sql.ResultSet = throw new UnsupportedOperationException + override def getResultSet(map: java.util.Map[String, Class[_]]): java.sql.ResultSet = throw new UnsupportedOperationException + override def getResultSet(index: Long, count: Int): java.sql.ResultSet = throw new UnsupportedOperationException + override def getResultSet(index: Long, count: Int, map: java.util.Map[String, Class[_]]): java.sql.ResultSet = throw new UnsupportedOperationException + override def free(): Unit = () + } +} diff --git a/balta/src/test/scala/za/co/absa/db/balta/classes/QueryResultUnitTests.scala b/balta/src/test/scala/za/co/absa/db/balta/classes/QueryResultUnitTests.scala index 43facdf..b4dfdf5 100644 --- a/balta/src/test/scala/za/co/absa/db/balta/classes/QueryResultUnitTests.scala +++ b/balta/src/test/scala/za/co/absa/db/balta/classes/QueryResultUnitTests.scala @@ -90,4 +90,15 @@ class QueryResultUnitTests extends AnyFunSuiteLike with MockResultSets { assert(qr.columnCount == 3) } + test("noMore returns false when rows are still available") { + val qr = new QueryResult(singleRowMockResultSet) + assert(!qr.noMore) + } + + test("noMore returns true after rows are consumed") { + val qr = new QueryResult(singleRowMockResultSet) + qr.next() + assert(qr.noMore) + } + } diff --git a/balta/src/test/scala/za/co/absa/db/balta/classes/inner/ParamsUnitTests.scala b/balta/src/test/scala/za/co/absa/db/balta/classes/inner/ParamsUnitTests.scala new file mode 100644 index 0000000..9660332 --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/balta/classes/inner/ParamsUnitTests.scala @@ -0,0 +1,108 @@ +/* + * 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.inner + +import org.scalatest.funsuite.AnyFunSuiteLike +import za.co.absa.db.balta.typeclasses.QueryParamType + +class ParamsUnitTests extends AnyFunSuiteLike { + + test("NamedParams add creates a single-entry list with correct key") { + val params = Params.add("name", "Alice") + assert(params.size == 1) + assert(params.keys.contains(List("name"))) + } + + test("NamedParams add preserves insertion order across multiple adds") { + val params = Params.add("a", 1).add("b", 2).add("c", 3) + assert(params.size == 3) + assert(params.keys.contains(List("a", "b", "c"))) + } + + test("NamedParams values returns parameter values in insertion order") { + val params = Params.add("x", 10).add("y", 20) + val values = params.values + assert(values.size == 2) + assert(values.head.sqlEntry == "?") + assert(values(1).sqlEntry == "?") + } + + test("NamedParams addNull creates a NULL parameter") { + val params = Params.addNull("missing") + assert(params.size == 1) + assert(params("missing").sqlEntry == "NULL") + assert(params("missing").equalityOperator == "IS") + } + + test("NamedParams instance addNull appends NULL parameter") { + val params = Params.add("id", 42).add("missing", QueryParamType.NULL) + assert(params.size == 2) + assert(params("missing").sqlEntry == "NULL") + assert(params("missing").equalityOperator == "IS") + } + + test("NamedParams pairs returns key-value list") { + val params = Params.add("k1", "v1").add("k2", "v2") + val pairs = params.pairs + assert(pairs.size == 2) + assert(pairs.head._1 == "k1") + assert(pairs(1)._1 == "k2") + } + + test("NamedParams apply retrieves value by parameter name") { + val params = Params.add("id", 42) + val value = params("id") + assert(value.sqlEntry == "?") + } + + test("NamedParams apply throws NoSuchElementException for missing key") { + val params = Params.add("id", 42) + assertThrows[NoSuchElementException] { + params("nonexistent") + } + } + + test("OrderedParams add creates a single-entry list") { + val params = Params.add(100) + assert(params.size == 1) + assert(params.keys.isEmpty) + } + + test("OrderedParams add preserves order across multiple adds") { + val params = Params.add(1).add(2).add(3) + assert(params.size == 3) + assert(params.values.size == 3) + } + + test("OrderedParams addNull creates a NULL parameter") { + val params = Params.addNull[QueryParamType.NULL.type]() + assert(params.size == 1) + assert(params.values.head.sqlEntry == "NULL") + } + + test("OrderedParams instance addNull appends NULL parameter") { + val params = Params.add(1).add(QueryParamType.NULL) + assert(params.size == 2) + assert(params.values.last.sqlEntry == "NULL") + assert(params.values.last.equalityOperator == "IS") + } + + test("OrderedParams keys returns None") { + val params = Params.add("hello") + assert(params.keys.isEmpty) + } +} diff --git a/balta/src/test/scala/za/co/absa/db/balta/testing/classes/RecordingPreparedStatement.scala b/balta/src/test/scala/za/co/absa/db/balta/testing/classes/RecordingPreparedStatement.scala new file mode 100644 index 0000000..03a6da0 --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/balta/testing/classes/RecordingPreparedStatement.scala @@ -0,0 +1,134 @@ +/* + * 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 java.io.{InputStream, Reader} +import java.net.URL +import java.sql._ +import java.util.Calendar + +/** + * Minimal PreparedStatement stub that records the last setter call. + * Used by unit tests that verify QueryParamType / QueryParamValue binding without a real DB. + */ +class RecordingPreparedStatement extends PreparedStatement { + var lastCall: (String, Any, Any) = _ + + override def setBoolean(parameterIndex: Int, x: Boolean): Unit = { lastCall = ("setBoolean", parameterIndex, x) } + override def setInt(parameterIndex: Int, x: Int): Unit = { lastCall = ("setInt", parameterIndex, x) } + override def setLong(parameterIndex: Int, x: Long): Unit = { lastCall = ("setLong", parameterIndex, x) } + override def setString(parameterIndex: Int, x: String): Unit = { lastCall = ("setString", parameterIndex, x) } + override def setDouble(parameterIndex: Int, x: Double): Unit = { lastCall = ("setDouble", parameterIndex, x) } + override def setFloat(parameterIndex: Int, x: Float): Unit = { lastCall = ("setFloat", parameterIndex, x) } + override def setBigDecimal(parameterIndex: Int, x: java.math.BigDecimal): Unit = { lastCall = ("setBigDecimal", parameterIndex, x) } + override def setDate(parameterIndex: Int, x: Date): Unit = { lastCall = ("setDate", parameterIndex, x) } + override def setTime(parameterIndex: Int, x: Time): Unit = { lastCall = ("setTime", parameterIndex, x) } + override def setObject(parameterIndex: Int, x: AnyRef): Unit = { lastCall = ("setObject", parameterIndex, x) } + + // Remaining PreparedStatement methods — no-op stubs + override def executeQuery(): ResultSet = null + override def executeUpdate(): Int = 0 + override def setNull(parameterIndex: Int, sqlType: Int): Unit = () + override def setByte(parameterIndex: Int, x: Byte): Unit = () + override def setShort(parameterIndex: Int, x: Short): Unit = () + override def setBytes(parameterIndex: Int, x: scala.Array[Byte]): Unit = () + override def setTimestamp(parameterIndex: Int, x: Timestamp): Unit = () + override def setAsciiStream(parameterIndex: Int, x: InputStream, length: Int): Unit = () + override def setUnicodeStream(parameterIndex: Int, x: InputStream, length: Int): Unit = () + override def setBinaryStream(parameterIndex: Int, x: InputStream, length: Int): Unit = () + override def clearParameters(): Unit = () + override def setObject(parameterIndex: Int, x: AnyRef, targetSqlType: Int): Unit = () + override def execute(): Boolean = false + override def addBatch(): Unit = () + override def setCharacterStream(parameterIndex: Int, reader: Reader, length: Int): Unit = () + override def setRef(parameterIndex: Int, x: Ref): Unit = () + override def setBlob(parameterIndex: Int, x: Blob): Unit = () + override def setClob(parameterIndex: Int, x: Clob): Unit = () + override def setArray(parameterIndex: Int, x: java.sql.Array): Unit = () + override def getMetaData: ResultSetMetaData = null + override def setDate(parameterIndex: Int, x: Date, cal: Calendar): Unit = () + override def setTime(parameterIndex: Int, x: Time, cal: Calendar): Unit = () + override def setTimestamp(parameterIndex: Int, x: Timestamp, cal: Calendar): Unit = () + override def setNull(parameterIndex: Int, sqlType: Int, typeName: String): Unit = () + override def setURL(parameterIndex: Int, x: URL): Unit = () + override def getParameterMetaData: ParameterMetaData = null + override def setRowId(parameterIndex: Int, x: RowId): Unit = () + override def setNString(parameterIndex: Int, value: String): Unit = () + override def setNCharacterStream(parameterIndex: Int, value: Reader, length: Long): Unit = () + override def setNClob(parameterIndex: Int, value: NClob): Unit = () + override def setClob(parameterIndex: Int, reader: Reader, length: Long): Unit = () + override def setBlob(parameterIndex: Int, inputStream: InputStream, length: Long): Unit = () + override def setNClob(parameterIndex: Int, reader: Reader, length: Long): Unit = () + override def setSQLXML(parameterIndex: Int, xmlObject: SQLXML): Unit = () + override def setObject(parameterIndex: Int, x: AnyRef, targetSqlType: Int, scaleOrLength: Int): Unit = () + override def setAsciiStream(parameterIndex: Int, x: InputStream, length: Long): Unit = () + override def setBinaryStream(parameterIndex: Int, x: InputStream, length: Long): Unit = () + override def setCharacterStream(parameterIndex: Int, reader: Reader, length: Long): Unit = () + override def setAsciiStream(parameterIndex: Int, x: InputStream): Unit = () + override def setBinaryStream(parameterIndex: Int, x: InputStream): Unit = () + override def setCharacterStream(parameterIndex: Int, reader: Reader): Unit = () + override def setNCharacterStream(parameterIndex: Int, value: Reader): Unit = () + override def setClob(parameterIndex: Int, reader: Reader): Unit = () + override def setBlob(parameterIndex: Int, inputStream: InputStream): Unit = () + override def setNClob(parameterIndex: Int, reader: Reader): Unit = () + + // Statement methods + override def executeQuery(sql: String): ResultSet = null + override def executeUpdate(sql: String): Int = 0 + override def close(): Unit = () + override def getMaxFieldSize: Int = 0 + override def setMaxFieldSize(max: Int): Unit = () + override def getMaxRows: Int = 0 + override def setMaxRows(max: Int): Unit = () + override def setEscapeProcessing(enable: Boolean): Unit = () + override def getQueryTimeout: Int = 0 + override def setQueryTimeout(seconds: Int): Unit = () + override def cancel(): Unit = () + override def getWarnings: SQLWarning = null + override def clearWarnings(): Unit = () + override def setCursorName(name: String): Unit = () + override def execute(sql: String): Boolean = false + override def getResultSet: ResultSet = null + override def getUpdateCount: Int = 0 + override def getMoreResults: Boolean = false + override def setFetchDirection(direction: Int): Unit = () + override def getFetchDirection: Int = 0 + override def setFetchSize(rows: Int): Unit = () + override def getFetchSize: Int = 0 + override def getResultSetConcurrency: Int = 0 + override def getResultSetType: Int = 0 + override def addBatch(sql: String): Unit = () + override def clearBatch(): Unit = () + override def executeBatch(): scala.Array[Int] = scala.Array.empty + override def getConnection: Connection = null + override def getMoreResults(current: Int): Boolean = false + override def getGeneratedKeys: ResultSet = null + override def executeUpdate(sql: String, autoGeneratedKeys: Int): Int = 0 + override def executeUpdate(sql: String, columnIndexes: scala.Array[Int]): Int = 0 + override def executeUpdate(sql: String, columnNames: scala.Array[String]): Int = 0 + override def execute(sql: String, autoGeneratedKeys: Int): Boolean = false + override def execute(sql: String, columnIndexes: scala.Array[Int]): Boolean = false + override def execute(sql: String, columnNames: scala.Array[String]): Boolean = false + override def getResultSetHoldability: Int = 0 + override def isClosed: Boolean = false + override def setPoolable(poolable: Boolean): Unit = () + override def isPoolable: Boolean = false + override def closeOnCompletion(): Unit = () + override def isCloseOnCompletion: Boolean = false + override def unwrap[T](iface: Class[T]): T = throw new UnsupportedOperationException + override def isWrapperFor(iface: Class[_]): Boolean = false +} diff --git a/balta/src/test/scala/za/co/absa/db/balta/typeclasses/QueryParamTypeUnitTests.scala b/balta/src/test/scala/za/co/absa/db/balta/typeclasses/QueryParamTypeUnitTests.scala new file mode 100644 index 0000000..2642dad --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/balta/typeclasses/QueryParamTypeUnitTests.scala @@ -0,0 +1,131 @@ +/* + * 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.typeclasses + +import java.time.{Instant, LocalDate, LocalTime, OffsetDateTime, ZoneOffset} +import java.util.UUID + +import org.scalatest.funsuite.AnyFunSuiteLike +import za.co.absa.db.balta.testing.classes.RecordingPreparedStatement + +class QueryParamTypeUnitTests extends AnyFunSuiteLike { + + test("QueryParamBoolean sets boolean on PreparedStatement") { + val ps = new RecordingPreparedStatement + val qpv = QueryParamType.QueryParamBoolean.toQueryParamValue(true) + qpv.assign.get(ps, 1) + assert(ps.lastCall == ("setBoolean", 1, true)) + } + + test("QueryParamInt sets int on PreparedStatement") { + val ps = new RecordingPreparedStatement + val qpv = QueryParamType.QueryParamInt.toQueryParamValue(42) + qpv.assign.get(ps, 1) + assert(ps.lastCall == ("setInt", 1, 42)) + } + + test("QueryParamLong sets long on PreparedStatement") { + val ps = new RecordingPreparedStatement + val qpv = QueryParamType.QueryParamLong.toQueryParamValue(123L) + qpv.assign.get(ps, 1) + assert(ps.lastCall == ("setLong", 1, 123L)) + } + + test("QueryParamString sets string on PreparedStatement") { + val ps = new RecordingPreparedStatement + val qpv = QueryParamType.QueryParamString.toQueryParamValue("hello") + qpv.assign.get(ps, 1) + assert(ps.lastCall == ("setString", 1, "hello")) + } + + test("QueryParamDouble sets double on PreparedStatement") { + val ps = new RecordingPreparedStatement + val qpv = QueryParamType.QueryParamDouble.toQueryParamValue(3.14) + qpv.assign.get(ps, 1) + assert(ps.lastCall == ("setDouble", 1, 3.14)) + } + + test("QueryParamFloat sets float on PreparedStatement") { + val ps = new RecordingPreparedStatement + val qpv = QueryParamType.QueryParamFloat.toQueryParamValue(2.5f) + qpv.assign.get(ps, 1) + assert(ps.lastCall == ("setFloat", 1, 2.5f)) + } + + test("QueryParamBigDecimal sets BigDecimal on PreparedStatement") { + val ps = new RecordingPreparedStatement + val bd = BigDecimal("99.99") + val qpv = QueryParamType.QueryParamBigDecimal.toQueryParamValue(bd) + qpv.assign.get(ps, 1) + assert(ps.lastCall == ("setBigDecimal", 1, bd.bigDecimal)) + } + + test("QueryParamChar sets string representation on PreparedStatement") { + val ps = new RecordingPreparedStatement + val qpv = QueryParamType.QueryParamChar.toQueryParamValue('A') + qpv.assign.get(ps, 1) + assert(ps.lastCall == ("setString", 1, "A")) + } + + test("QueryParamInstant sets OffsetDateTime via setObject") { + val ps = new RecordingPreparedStatement + val instant = Instant.parse("2024-01-15T10:30:00Z") + val qpv = QueryParamType.QueryParamInstant.toQueryParamValue(instant) + qpv.assign.get(ps, 1) + assert(ps.lastCall == ("setObject", 1, OffsetDateTime.ofInstant(instant, ZoneOffset.UTC))) + } + + test("QueryParamOffsetDateTime sets object on PreparedStatement") { + val ps = new RecordingPreparedStatement + val odt = OffsetDateTime.of(2024, 1, 15, 10, 30, 0, 0, ZoneOffset.UTC) + val qpv = QueryParamType.QueryParamOffsetDateTime.toQueryParamValue(odt) + qpv.assign.get(ps, 1) + assert(ps.lastCall == ("setObject", 1, odt)) + } + + test("QueryParamLocalTime sets Time on PreparedStatement") { + val ps = new RecordingPreparedStatement + val lt = LocalTime.of(14, 30, 0) + val qpv = QueryParamType.QueryParamLocalTime.toQueryParamValue(lt) + qpv.assign.get(ps, 1) + assert(ps.lastCall == ("setTime", 1, java.sql.Time.valueOf(lt))) + } + + test("QueryParamLocalDate sets Date on PreparedStatement") { + val ps = new RecordingPreparedStatement + val ld = LocalDate.of(2024, 6, 15) + val qpv = QueryParamType.QueryParamLocalDate.toQueryParamValue(ld) + qpv.assign.get(ps, 1) + assert(ps.lastCall == ("setDate", 1, java.sql.Date.valueOf(ld))) + } + + test("QueryParamUUID sets object on PreparedStatement") { + val ps = new RecordingPreparedStatement + val uuid = UUID.fromString("550e8400-e29b-41d4-a716-446655440000") + val qpv = QueryParamType.QueryParamUUID.toQueryParamValue(uuid) + qpv.assign.get(ps, 1) + assert(ps.lastCall == ("setObject", 1, uuid)) + } + + test("QueryParamNull returns NullParamValue with no assign function") { + val qpv = QueryParamType.QueryParamNull.toQueryParamValue(QueryParamType.NULL) + assert(qpv.assign.isEmpty) + assert(qpv.sqlEntry == "NULL") + assert(qpv.equalityOperator == "IS") + } + +} diff --git a/balta/src/test/scala/za/co/absa/db/balta/typeclasses/QueryParamValueUnitTests.scala b/balta/src/test/scala/za/co/absa/db/balta/typeclasses/QueryParamValueUnitTests.scala new file mode 100644 index 0000000..48bb3bf --- /dev/null +++ b/balta/src/test/scala/za/co/absa/db/balta/typeclasses/QueryParamValueUnitTests.scala @@ -0,0 +1,67 @@ +/* + * 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.typeclasses + +import org.scalatest.funsuite.AnyFunSuiteLike +import za.co.absa.db.balta.testing.classes.RecordingPreparedStatement +import za.co.absa.db.balta.typeclasses.QueryParamValue._ + +class QueryParamValueUnitTests extends AnyFunSuiteLike { + + test("ObjectQueryParamValue.assign calls setObject with the wrapped object") { + val obj = java.lang.Integer.valueOf(42) + val qpv = new ObjectQueryParamValue(obj) + assert(qpv.assign.isDefined) + val ps = new RecordingPreparedStatement + qpv.assign.get(ps, 3) + assert(ps.lastCall == ("setObject", 3, obj)) + } + + test("ObjectQueryParamValue uses default sqlEntry and equalityOperator") { + val qpv = new ObjectQueryParamValue("test") + assert(qpv.sqlEntry == "?") + assert(qpv.equalityOperator == "=") + } + + test("SimpleQueryParamValue.assign invokes the provided function") { + var called = false + val qpv = new SimpleQueryParamValue((ps, pos) => { called = true }) + assert(qpv.assign.isDefined) + val ps = new RecordingPreparedStatement + qpv.assign.get(ps, 1) + assert(called) + } + + test("SimpleQueryParamValue uses default sqlEntry and equalityOperator") { + val qpv = new SimpleQueryParamValue((_, _) => ()) + assert(qpv.sqlEntry == "?") + assert(qpv.equalityOperator == "=") + } + + test("NullParamValue.assign is None") { + assert(NullParamValue.assign.isEmpty) + } + + test("NullParamValue.sqlEntry is NULL") { + assert(NullParamValue.sqlEntry == "NULL") + } + + test("NullParamValue.equalityOperator is IS") { + assert(NullParamValue.equalityOperator == "IS") + } + +} 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 index fdd555d..5fdd575 100644 --- 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 @@ -29,6 +29,14 @@ class SnakeCaseNamingUnitTests extends AnyWordSpec with Matchers { val nm = new SnakeCaseNaming(AsIs) nm.stringPerConvention("") should be("") } + "handle string starting with uppercase" in { + val nm = new SnakeCaseNaming(AsIs) + nm.stringPerConvention("Name") should be("Name") + } + "handle single lowercase word" in { + val nm = new SnakeCaseNaming(LowerCase) + nm.stringPerConvention("hello") should be("hello") + } } "fromClassNamePerConvention" should { @@ -49,5 +57,10 @@ class SnakeCaseNamingUnitTests extends AnyWordSpec with Matchers { result should be("THIS_IS_A_TEST_CLASS") } } + "strip trailing $ from companion object class names" in { + val nm = new SnakeCaseNaming(LowerCase) + val result = nm.fromClassNamePerConvention(SnakeCaseNaming) + result should be("snake_case_naming") + } } } diff --git a/build.sbt b/build.sbt index fac35c2..9b9abac 100644 --- a/build.sbt +++ b/build.sbt @@ -38,4 +38,6 @@ lazy val balta = (project in file("balta")) ), crossScalaVersions := supportedScalaVersions, libraryDependencies ++= libDependencies, + jmfReportFile := Some(target.value / "jmf-report.json"), + jmfReportFormat := "json", ) diff --git a/jmf-rules.txt b/jmf-rules.txt index d5323fb..248e8cb 100644 --- a/jmf-rules.txt +++ b/jmf-rules.txt @@ -1,16 +1,34 @@ -# jacoco-method-filter — Default Rules & HowTo (Scala) -# [jmf:1.0.0] - -# GLOBALS RULES -# ** all case class boilerplate +# jacoco-method-filter — Rules Template (Scala / sbt) +# [jmf:2.1.0] +# +# Syntax reference, pitfalls, examples, and workflows: https://github.com/MoranaApps/jacoco-method-filter/blob/main/docs/rules-reference.md +# +# ───────────────────────────────────────────────────────────────────────────── +# HOW TO USE +# ───────────────────────────────────────────────────────────────────────────── +# +# 1) Review the GLOBAL RULES below — they cover compiler-generated boilerplate. +# 2) Add project-specific patterns in the PROJECT RULES section. +# 3) Keep rules narrow; add id: labels so logs are readable. +# Every rule must have an id: