diff --git a/.gitignore b/.gitignore index 75ec9f0..bad0ee7 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ build.log .hg* .bsp +.DS_Store diff --git a/jmf-rules.txt b/jmf-rules.txt index 0ab8685..71bfa06 100644 --- a/jmf-rules.txt +++ b/jmf-rules.txt @@ -156,4 +156,4 @@ # Commented out to reconsider if needed + waiting for jacoco-report bug fix -# za.co.absa.mag.* +za.co.absa.mag.exceptions.*#* diff --git a/mag/src/main/scala/za/co/absa/db/mag/core/ColumnReference.scala b/mag/src/main/scala/za/co/absa/db/mag/core/ColumnReference.scala new file mode 100644 index 0000000..51a246b --- /dev/null +++ b/mag/src/main/scala/za/co/absa/db/mag/core/ColumnReference.scala @@ -0,0 +1,86 @@ +/* + * Copyright 2026 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.core + +trait ColumnReference extends SqlItem + +case class ColumnName(enteredName: String, sqlEntry: SqlEntry, quoteLess: String) extends ColumnReference { + override def equals(obj: Any): Boolean = { + obj match { + case that: ColumnReference => this.sqlEntry == that.sqlEntry + case _ => false + } + } + override def hashCode(): Int = sqlEntry.hashCode +} + +object ColumnReference { + private val regularColumnNamePattern = "^([a-z_][a-z0-9_]*)$".r + private val mixedCaseColumnNamePattern = "^[a-zA-Z_][a-zA-Z0-9_]*$".r + private val quotedRegularColumnNamePattern = "^\"([a-z_][a-z0-9_]*)\"$".r + private val quotedColumnNamePattern = "^\"(.+)\"$".r + + private[core] def quote(stringToQuote: String): String = s""""$stringToQuote"""" + private[core] def escapeQuote(stringToEscape: String): String = stringToEscape.replace("\"", "\"\"") + private[core] def hasUnescapedQuotes(name: String): Boolean = { + val reduced = name.replace("\"\"", "") + reduced.contains('"') + } + + def apply(name: String): ColumnName = { + val trimmedName = name.trim + trimmedName match { + case regularColumnNamePattern(columnName) => + ColumnName(columnName, SqlEntry(columnName), columnName) // column name per SQL standard, no quoting needed + case mixedCaseColumnNamePattern() => + val loweredColumnName = trimmedName.toLowerCase + ColumnName( + trimmedName, + SqlEntry(loweredColumnName), + loweredColumnName + ) // mixed case name, turn to lower case for sql entry (per standard) + case quotedRegularColumnNamePattern(columnName) => + ColumnName(trimmedName, SqlEntry(columnName), columnName) // quoted but regular name, remove quotes + case quotedColumnNamePattern(actualColumnName) => + if (hasUnescapedQuotes(actualColumnName)) { + throw new IllegalArgumentException( + s"Column name '$actualColumnName' has unescaped quotes. Use double quotes as escape sequence." + ) + } + val unescapedColumnName = actualColumnName.replace("\"\"", "\"") + ColumnName(trimmedName, SqlEntry(trimmedName), unescapedColumnName) // quoted name, use as is + case _ => + ColumnName( + trimmedName, + SqlEntry(quote(escapeQuote(trimmedName))), + trimmedName + ) // needs quoting and perhaps escaping + } + } + + def apply(index: Int): ColumnReference = { + ColumnIndex(index) + } + + final case class ColumnIndex private (index: Int) extends ColumnReference { + val sqlEntry: SqlEntry = SqlEntry(index.toString) + } +} + +object ColumnName { + def apply(name: String): ColumnName = ColumnReference(name) +} diff --git a/mag/src/main/scala/za/co/absa/db/mag/core/SqlEntry.scala b/mag/src/main/scala/za/co/absa/db/mag/core/SqlEntry.scala index eaa314c..0b276e5 100644 --- a/mag/src/main/scala/za/co/absa/db/mag/core/SqlEntry.scala +++ b/mag/src/main/scala/za/co/absa/db/mag/core/SqlEntry.scala @@ -16,4 +16,100 @@ package za.co.absa.db.mag.core -class SqlEntry(val entry: String) extends AnyVal +import za.co.absa.db.mag.core.SqlEntry.concat + +import scala.language.implicitConversions + +class SqlEntry(val entry: String) extends AnyVal { + def +(other: SqlEntry): SqlEntry = concat(this.toOption, other.toOption).toSqlEntry + + def +(other: Option[SqlEntry]): SqlEntry = { + other match { + case None => this + case _ => concat(this.toOption, other).toSqlEntry + } + } + + def ==(other: String): Boolean = this.entry == other + def :=(other: SqlEntry): SqlEntry = this + SqlEntry(":=") + other + def apply(params: String*): SqlEntry = { + val paramsStr = params.mkString("(", ",", ")") + this + SqlEntry(paramsStr) + } + + /** Translates a sequence of SqlEntry entries into a single SqlEntry formatted as a parameter list + * + * @param params - A sequence of SqlEntry to be included as parameters. + * @param disambiguator - Unused parameter to differentiate this method from `apply(params: String*)` + * after JVM type erasure (both would have signature `apply(Seq)`) + * @return - A new SqlEntry that combines the input as a list of parameters/columns for a function or table. + */ + def apply(params: Seq[SqlEntry], disambiguator: String = ""): SqlEntry = { + val paramsEntry = params.mkSqlEntry("(", ",", ")") + this + paramsEntry + } + + def toOption: Option[SqlEntry] = { + if (this.entry.trim.isEmpty) None else Some(this) + } + + override def toString: String = entry + +} + +object SqlEntry { + def apply(entry: String): SqlEntry = new SqlEntry(entry) + def apply(firstColumn: ColumnReference, otherColumns: ColumnReference*): SqlEntry = { + val allColumns = (firstColumn +: otherColumns).map(_.sqlEntry).mkString(", ") + SqlEntry(allColumns) + } + + def apply(maybeEntry: Option[String]): Option[SqlEntry] = maybeEntry.map(SqlEntry(_)) + + implicit class SqlEntryOptionEnhancement(val sqlEntry: Option[SqlEntry]) extends AnyVal { + def prefix(withEntry: SqlEntry): Option[SqlEntry] = sqlEntry.map(withEntry + _) + + def + (other: Option[SqlEntry]): Option[SqlEntry] = concat(sqlEntry, other) + + def + (other: SqlEntry): SqlEntry = concat(sqlEntry, other.toOption).toSqlEntry + + def toSqlEntry: SqlEntry = { + sqlEntry.getOrElse(SqlEntry("")) + } + } + + implicit class SqlEntryListEnhancement(val sqlEntries: Seq[SqlEntry]) extends AnyVal { + def mkSqlEntry(separator: String): SqlEntry = { + mkSqlEntry("", separator, "") + } + def mkSqlEntry(start: String, separator: String = ", ", end: String): SqlEntry = { + val entriesStr = sqlEntries.map(_.entry).mkString(start, separator, end) + SqlEntry(entriesStr) + } + } + + implicit def sqlEntryToString(sqlEntry: SqlEntry): String = sqlEntry.entry + + private def concat(first: Option[SqlEntry], second: Option[SqlEntry]): Option[SqlEntry] = { + (first, second) match { + case (None, None) => None + case (None, Some(_)) => second + case (Some(_), None) => first + case (Some(e1), Some(e2)) => + val first = e1.entry + val second = e2.entry + val e2Start = e2.entry(0) // first character of second.entry, can never be empty here because of the previous cases + val e1End = e1.entry.last // last character of the first.entry, can never be empty here because of the previous cases + val noSpaceStartChars = Set(',', '(', ')', '.') + val noSpaceEndChars = Set('(') + if (noSpaceStartChars.contains(e2Start) || noSpaceEndChars.contains(e1End)) { + // It looks better to have no space before characters like , ( ) + Some(SqlEntry(s"$first$second")) + } else { + Some(SqlEntry(s"$first $second")) + } + + } + } + +} diff --git a/mag/src/main/scala/za/co/absa/db/mag/core/SqlEntryComposition.scala b/mag/src/main/scala/za/co/absa/db/mag/core/SqlEntryComposition.scala new file mode 100644 index 0000000..7c0eb71 --- /dev/null +++ b/mag/src/main/scala/za/co/absa/db/mag/core/SqlEntryComposition.scala @@ -0,0 +1,146 @@ +/* + * Copyright 2026 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.core + +import scala.language.implicitConversions +import za.co.absa.db.mag.core.SqlEntry._ + +object SqlEntryComposition { + + sealed class SelectFragment private[SqlEntryComposition]() { + def apply(firstField: ColumnReference, fields: ColumnReference*): SelectWithFieldsFragment = { + val allFields = firstField +: fields + new SelectWithFieldsFragment(select + allFields.map(_.sqlEntry).mkSqlEntry(", ")) + } + + def apply(sqlConstant: SqlEntryConstant): SelectWithFieldsFragment = new SelectWithFieldsFragment(select + sqlConstant.sqlConstant) + } + + sealed class InsertFragment private[SqlEntryComposition]() { + def INTO(intoEntry: SqlEntry): QueryInsertIntoFragment = { + new QueryInsertIntoFragment(insertInto + intoEntry) + } + } + + sealed class QueryInsertIntoFragment private[SqlEntryComposition](sqlEntry: SqlEntry) { + def VALUES(firstValue: String, otherValues: String*): QueryInsert = { + val values = firstValue +: otherValues + VALUES(values.map(SqlEntry(_))) + } + def VALUES(values: Seq[SqlEntry]): QueryInsert = { + val valuesEntry = values.mkSqlEntry("VALUES(", ", ", ")") + new QueryInsert(sqlEntry + valuesEntry) + } + } + + sealed class DeleteFragment private[SqlEntryComposition]() { + def FROM(fromEntry: SqlEntry): QueryDelete = new QueryDelete(deleteFrom + fromEntry) + } + + private object SelectFragment extends SelectFragment() + private object InsertFragment extends InsertFragment() + private object DeleteFragment extends DeleteFragment + + sealed class SelectWithFieldsFragment private[SqlEntryComposition](val sql: SqlEntry) { + def FROM(fromEntry: SqlEntry): QuerySelect = new QuerySelect(sql + from + fromEntry) + } + + sealed class OrderByFragment private[SqlEntryComposition](orderingEntry: Option[SqlEntry]) { + val sqlEntry: Option[SqlEntry] = orderingEntry.prefix(orderBy) + } + + sealed trait OrderByMixIn { + def sqlEntry: SqlEntry + def ORDER(by: OrderByFragment): QueryComplete = new QueryComplete(sqlEntry + by.sqlEntry) + } + + sealed trait ReturningMixIn { + def sqlEntry: SqlEntry + def RETURNING(returningFields: SqlEntryConstant): QueryWithReturning = { + new QueryWithReturning(sqlEntry + returning + returningFields.sqlConstant) + } + def RETURNING(firstField: ColumnReference, otherFields: ColumnReference*): QueryWithReturning = { + val allFields = firstField +: otherFields + new QueryWithReturning(sqlEntry + returning + columnsToSqlEntry(allFields)) + } + } + + sealed class Query(val sqlEntry: SqlEntry) + + sealed class QuerySelect private[SqlEntryComposition](sqlEntry: SqlEntry) + extends Query(sqlEntry) with OrderByMixIn { + def WHERE(condition: SqlEntry): QuerySelectConditioned = WHERE(condition.toOption) + def WHERE(condition: Option[SqlEntry]): QuerySelectConditioned = { + new QuerySelectConditioned(sqlEntry + condition.prefix(where)) + } + } + + sealed class QuerySelectConditioned private[SqlEntryComposition](sqlEntry: SqlEntry) + extends Query(sqlEntry) with OrderByMixIn { + } + + sealed class QueryInsert private[SqlEntryComposition](sqlEntry: SqlEntry) + extends Query(sqlEntry) with ReturningMixIn { + } + + sealed class QueryDelete private[SqlEntryComposition](sqlEntry: SqlEntry) extends Query(sqlEntry) with ReturningMixIn { + def WHERE(condition: SqlEntry): QueryDeleteConditioned = WHERE(condition.toOption) + def WHERE(condition: Option[SqlEntry]): QueryDeleteConditioned = { + new QueryDeleteConditioned(sqlEntry + condition.prefix(where)) + } + } + + sealed class QueryDeleteConditioned private[SqlEntryComposition](sqlEntry: SqlEntry) + extends Query(sqlEntry) with ReturningMixIn { + } + + sealed class QueryComplete(sqlEntry: SqlEntry) extends Query(sqlEntry) + + sealed class QueryWithReturning(sqlEntry: SqlEntry) extends Query(sqlEntry) + + sealed class SqlEntryConstant private[mag](val sqlConstant: SqlEntry) + + val ALL: SqlEntryConstant = new SqlEntryConstant(SqlEntry("*")) + val COUNT_ALL: SqlEntryConstant = new SqlEntryConstant(SqlEntry("count(1) AS cnt")) + + def SELECT: SelectFragment = SelectFragment + def INSERT: InsertFragment = InsertFragment + def DELETE: DeleteFragment = DeleteFragment + def BY(by: SqlEntry): OrderByFragment = BY(by.toOption) + def BY(by: Option[SqlEntry]): OrderByFragment = new OrderByFragment(by) + def BY(columns: ColumnReference*): OrderByFragment = new OrderByFragment(columnsToSqlEntry(columns)) + + implicit def QueryToSqlEntry(query: Query): SqlEntry = query.sqlEntry + + private val select = SqlEntry("SELECT") + private val insertInto = SqlEntry("INSERT INTO") + private val deleteFrom = SqlEntry("DELETE FROM") + private val from = SqlEntry("FROM") + private val where = SqlEntry("WHERE") + private val orderBy = SqlEntry("ORDER BY") + private val returning = SqlEntry("RETURNING") + + private def columnsToSqlEntry(fields: Seq[ColumnReference]): Option[SqlEntry] = { + if (fields.isEmpty) { + None + } else { + val fieldEntries = fields.map(_.sqlEntry.entry) + Some(SqlEntry(fieldEntries.mkString(", "))) + } + } + +} diff --git a/mag/src/main/scala/za/co/absa/db/mag/exceptions/NamingException.scala b/mag/src/main/scala/za/co/absa/db/mag/exceptions/NamingException.scala new file mode 100644 index 0000000..59ff5ce --- /dev/null +++ b/mag/src/main/scala/za/co/absa/db/mag/exceptions/NamingException.scala @@ -0,0 +1,22 @@ +/* + * Copyright 2026 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.exceptions + +/** + * Exception thrown when a naming convention is not found for a given string + */ +case class NamingException(message: String) extends Exception(message) diff --git a/mag/src/main/scala/za/co/absa/db/mag/implicits/MapImplicits.scala b/mag/src/main/scala/za/co/absa/db/mag/implicits/MapImplicits.scala new file mode 100644 index 0000000..574dc42 --- /dev/null +++ b/mag/src/main/scala/za/co/absa/db/mag/implicits/MapImplicits.scala @@ -0,0 +1,33 @@ +/* + * Copyright 2026 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.implicits + +object MapImplicits { + implicit class MapEnhancements[K, V](val map: Map[K, V]) extends AnyVal { + /** + * Gets the value associated with the key or throws the provided exception + * @param key - the key to get the value for + * @param exception - the exception to throw in case the `option` is None + * @tparam V1 - the type of the value + * @return - the value associated with key if it exists, otherwise throws the provided exception + */ + def getOrThrow[V1 >: V](key: K, exception: => Throwable): V1 = { + map.getOrElse(key, throw exception) + } + } + +} diff --git a/mag/src/main/scala/za/co/absa/db/mag/implicits/OptionImplicits.scala b/mag/src/main/scala/za/co/absa/db/mag/implicits/OptionImplicits.scala new file mode 100644 index 0000000..52fec30 --- /dev/null +++ b/mag/src/main/scala/za/co/absa/db/mag/implicits/OptionImplicits.scala @@ -0,0 +1,40 @@ +/* + * Copyright 2026 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.implicits + +object OptionImplicits { + implicit class OptionEnhancements[T](val option: Option[T]) extends AnyVal { + /** + * Gets the `option` value or throws the provided exception + * + * @param exception the exception to throw in case the `option` is None + * @return + */ + def getOrThrow(exception: => Throwable): T = { + option.getOrElse(throw exception) + } + + /** + * The function is an alias for `contains` method, but shorter and suitable for inflix usage + * + * @param value the value to check if present in the `option` + * @return true if the `option` is defined and contains the provided value, false otherwise + */ + def @=(value: T): Boolean = option.contains(value) + } + +} diff --git a/mag/src/main/scala/za/co/absa/db/mag/naming/LettersCase.scala b/mag/src/main/scala/za/co/absa/db/mag/naming/LettersCase.scala new file mode 100644 index 0000000..7227cd5 --- /dev/null +++ b/mag/src/main/scala/za/co/absa/db/mag/naming/LettersCase.scala @@ -0,0 +1,55 @@ +/* + * Copyright 2026 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming + +/** + * `LettersCase` is a sealed trait that represents different cases of letters. + * It provides a method to convert a string to the specific case. + */ +sealed trait LettersCase { + + /** + * Converts a string to the specific case. + * @param s - The original string. + * @return The string converted to the specific case. + */ + def convert(s: String): String +} + +object LettersCase { + + /** + * `AsIs` is a [[LettersCase]] that leaves strings as they are. + */ + case object AsIs extends LettersCase { + override def convert(s: String): String = s + } + + /** + * `LowerCase` is a [[LettersCase]] that converts strings to lower case. + */ + case object LowerCase extends LettersCase { + override def convert(s: String): String = s.toLowerCase + } + + /** + * `UpperCase` is a [[LettersCase]] that converts strings to upper case. + */ + case object UpperCase extends LettersCase { + override def convert(s: String): String = s.toUpperCase + } +} diff --git a/mag/src/main/scala/za/co/absa/db/mag/naming/NamingConvention.scala b/mag/src/main/scala/za/co/absa/db/mag/naming/NamingConvention.scala new file mode 100644 index 0000000..b2952f7 --- /dev/null +++ b/mag/src/main/scala/za/co/absa/db/mag/naming/NamingConvention.scala @@ -0,0 +1,54 @@ +/* + * Copyright 2026 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming + +/** + * `NamingConvention` is a base trait that defines the interface for different naming conventions. + * It provides methods to convert a class name according to given naming convention. + */ +trait NamingConvention { + + /** + * Converts the class name according to the specific naming convention. + * @param c - The class. + * @return The class name converted to string according to the specific naming convention. + */ + def fromClassNamePerConvention(c: Class[_]): String = { + val className = c.getSimpleName + val cleanClassName = className.lastIndexOf('$') match { + case -1 => className + case x => className.substring(0, x) + } + stringPerConvention(cleanClassName) + } + + /** + * Converts the class name according to the specific naming convention. + * @param instance - The instance of the class. + * @return The class name converted to string according to the specific naming convention. + */ + def fromClassNamePerConvention(instance: AnyRef): String = { + fromClassNamePerConvention(instance.getClass) + } + + /** + * Converts the original string according to the specific naming convention. + * @param original - The original string. + * @return The original string converted according to the specific naming convention. + */ + def stringPerConvention(original: String): String +} diff --git a/mag/src/main/scala/za/co/absa/db/mag/naming/implementations/AsIsNaming.scala b/mag/src/main/scala/za/co/absa/db/mag/naming/implementations/AsIsNaming.scala new file mode 100644 index 0000000..e0365ae --- /dev/null +++ b/mag/src/main/scala/za/co/absa/db/mag/naming/implementations/AsIsNaming.scala @@ -0,0 +1,50 @@ +/* + * Copyright 2026 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import za.co.absa.db.mag.naming.LettersCase.AsIs +import za.co.absa.db.mag.naming.{LettersCase, NamingConvention} + +/** + * `AsIsNaming` provides a naming convention that leaves strings as they are. + * It implements the [[NamingConvention]] trait. + * @param lettersCase - The case of the letters in the string. + */ +class AsIsNaming(lettersCase: LettersCase) extends NamingConvention { + + /** + * Returns the original string converted to the specified letter case. + * @param original - The original string. + * @return The original string converted to the specified letter case. + */ + override def stringPerConvention(original: String): String = { + lettersCase.convert(original) + } +} + +/** + * `AsIsNaming.Implicits` provides an implicit [[NamingConvention]] instance that leaves strings as they are. + */ +object AsIsNaming { + object Implicits { + + /** + * An implicit [[NamingConvention]] instance that leaves strings as they are. + */ + implicit val namingConvention: NamingConvention = new AsIsNaming(AsIs) + } +} diff --git a/mag/src/main/scala/za/co/absa/db/mag/naming/implementations/ExplicitNamingRequired.scala b/mag/src/main/scala/za/co/absa/db/mag/naming/implementations/ExplicitNamingRequired.scala new file mode 100644 index 0000000..6be786c --- /dev/null +++ b/mag/src/main/scala/za/co/absa/db/mag/naming/implementations/ExplicitNamingRequired.scala @@ -0,0 +1,51 @@ +/* + * Copyright 2026 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import za.co.absa.db.mag.exceptions.NamingException +import za.co.absa.db.mag.naming.NamingConvention + +/** + * `ExplicitNamingRequired` is a `NamingConvention` that throws a `NamingException` for any string. + * This is used when explicit naming is required and no other naming convention should be applied. + */ +class ExplicitNamingRequired extends NamingConvention { + + /** + * Throws a `NamingConvention` with a message indicating that explicit naming is required. + * @param original - The original string. + * @return Nothing, as a `NamingException` is always thrown. + */ + override def stringPerConvention(original: String): String = { + val message = s"No convention for '$original', explicit naming required." + throw NamingException(message) + } +} + +/** + * `ExplicitNamingRequired.Implicits` provides an implicit `NamingConvention` instance that + * throws a `NamingException` for any string. + */ +object ExplicitNamingRequired { + object Implicits { + + /** + * An implicit `NamingConvention` instance that throws a `NamingException` for any string. + */ + implicit val namingConvention: NamingConvention = new ExplicitNamingRequired() + } +} diff --git a/mag/src/main/scala/za/co/absa/db/mag/naming/implementations/MapBasedNaming.scala b/mag/src/main/scala/za/co/absa/db/mag/naming/implementations/MapBasedNaming.scala new file mode 100644 index 0000000..e684304 --- /dev/null +++ b/mag/src/main/scala/za/co/absa/db/mag/naming/implementations/MapBasedNaming.scala @@ -0,0 +1,52 @@ +/* + * Copyright 2026 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import za.co.absa.db.mag.implicits.MapImplicits.MapEnhancements +import za.co.absa.db.mag.exceptions.NamingException +import za.co.absa.db.mag.naming.LettersCase.AsIs +import za.co.absa.db.mag.naming.{LettersCase, NamingConvention} + +/** + * `MapBasedNaming` requires an explicit map of name conversions provided in a form of a `Map[String, String]`. + * If the requested name is not found in the map, a `NamingException` is thrown. + */ +class MapBasedNaming private(names: Map[String, String], lettersCase: LettersCase) extends NamingConvention { + + /** + * Throws a `NamingConvention` if the original is not present between the keys of the Map. + * @param original - The original string. + * @return - The string from the map linked to the original string. + */ + override def stringPerConvention(original: String): String = { + names.getOrThrow(lettersCase.convert(original), NamingException(s"No convention for '$original' has been defined.")) + } +} + +object MapBasedNaming { + /** + * Creates a new `MapBasedNaming` instance with the specified names and letter cases. + * @param names - The map of names. + * @param keysLettersCase - The case of the keys in the map. Input values are converted to this case upon querying. + * @param valueLettersCase - The case of the values in the map. + * @return - The string from the map linked to the original string. + */ + def apply(names: Map[String, String], keysLettersCase: LettersCase = AsIs, valueLettersCase: LettersCase = AsIs): NamingConvention = { + val actualNames = names.map { case (k, v) => (keysLettersCase.convert(k), valueLettersCase.convert(v)) } + new MapBasedNaming(actualNames, keysLettersCase) + } +} diff --git a/mag/src/main/scala/za/co/absa/db/mag/naming/implementations/SnakeCaseNaming.scala b/mag/src/main/scala/za/co/absa/db/mag/naming/implementations/SnakeCaseNaming.scala new file mode 100644 index 0000000..31c5a9f --- /dev/null +++ b/mag/src/main/scala/za/co/absa/db/mag/naming/implementations/SnakeCaseNaming.scala @@ -0,0 +1,65 @@ +/* + * Copyright 2026 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import za.co.absa.db.mag.naming.LettersCase.LowerCase +import za.co.absa.db.mag.naming.{LettersCase, NamingConvention} + +/** + * `SnakeCaseNaming` provides a naming convention that converts camel case strings to snake case. + * It implements the [[NamingConvention]] trait. + * + * @param lettersCase - The case of the letters in the string. + */ +class SnakeCaseNaming(lettersCase: LettersCase) extends NamingConvention { + + private def camelCaseToSnakeCase(s: String): String = { + s.replaceAll("([A-Z])", "_$1") + } + + private def stripIfFirstChar(s: String, ch: Char): String = { + if (s == "") { + s + } else if (s(0) == ch) { + s.substring(1) + } else { + s + } + } + + /** + * Converts the original string to snake case and the specified letter case. + * @param original - The original string. + * @return The original string converted to snake case and the specified letter case. + */ + override def stringPerConvention(original: String): String = { + lettersCase.convert(stripIfFirstChar(camelCaseToSnakeCase(original), '_')) + } +} + +/** + * `SnakeCaseNaming.Implicits` provides an implicit [[NamingConvention]] instance that converts camel case strings to snake case. + */ +object SnakeCaseNaming { + object Implicits { + + /** + * An implicit [[NamingConvention]] instance that converts camel case strings to snake case. + */ + implicit val namingConvention: NamingConvention = new SnakeCaseNaming(LowerCase) + } +} diff --git a/mag/src/test/scala/za/co/absa/db/mag/core/ColumnReferenceUnitTests.scala b/mag/src/test/scala/za/co/absa/db/mag/core/ColumnReferenceUnitTests.scala new file mode 100644 index 0000000..a36f06a --- /dev/null +++ b/mag/src/test/scala/za/co/absa/db/mag/core/ColumnReferenceUnitTests.scala @@ -0,0 +1,162 @@ +/* + * Copyright 2026 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.core + +import org.scalatest.funsuite.AnyFunSuiteLike + +class ColumnReferenceUnitTests extends AnyFunSuiteLike{ + test("Simple regular name") { + val simple = ColumnReference("col1") + assert(simple.enteredName == "col1") + assert(simple.sqlEntry == "col1") + assert(simple.quoteLess == "col1") + } + + test("Quoted regular name doesn't need quoting") { + val quotedExact = ColumnReference("\"abc\"") + assert(quotedExact.enteredName == "\"abc\"") + assert(quotedExact.sqlEntry == "abc") + assert(quotedExact.quoteLess == "abc") + } + + test("Quoted string is used literally") { + val quotedGeneral = ColumnReference("\"Col Name\"") + assert(quotedGeneral.enteredName == """"Col Name"""") + assert(quotedGeneral.sqlEntry == """"Col Name"""") + assert(quotedGeneral.quoteLess == """Col Name""") + } + + test("Contains a non-regular character - capital letter, it's lowercased") { + val defaultQuoted = ColumnReference("someName") + assert(defaultQuoted.enteredName == "someName") + assert(defaultQuoted.sqlEntry == "somename") + assert(defaultQuoted.quoteLess == "somename") + } + + test("Contains a non-regular character - Space") { + val defaultQuoted = ColumnReference("some name") + assert(defaultQuoted.enteredName == "some name") + assert(defaultQuoted.sqlEntry == "\"some name\"") + assert(defaultQuoted.quoteLess == "some name") + } + + test("Contains a non-regular character - non-ASCII letter") { + val defaultQuoted = ColumnReference("lék") + assert(defaultQuoted.enteredName == "lék") + assert(defaultQuoted.sqlEntry == "\"lék\"") + assert(defaultQuoted.quoteLess == "lék") + } + + test("Contains a non-regular character - dash") { + val defaultQuoted = ColumnReference("some-name") + assert(defaultQuoted.enteredName == "some-name") + assert(defaultQuoted.sqlEntry == "\"some-name\"") + assert(defaultQuoted.quoteLess == "some-name") + } + + test("Contains a non-regular character - quote") { + val containsQuote = ColumnReference("a\"bc") + assert(containsQuote.enteredName == "a\"bc") + assert(containsQuote.sqlEntry == "\"a\"\"bc\"") + assert(containsQuote.quoteLess == "a\"bc") + } + + test("Starts with a number") { + val defaultQuoted = ColumnReference("123") + assert(defaultQuoted.enteredName == "123") + assert(defaultQuoted.sqlEntry == "\"123\"") + assert(defaultQuoted.quoteLess == "123") + } + + test("Properly escaped name containing quotes") { + val columnName = ColumnReference(""""Has ""Quotes""!"""") + assert(columnName.enteredName == """"Has ""Quotes""!"""") + assert(columnName.sqlEntry == """"Has ""Quotes""!"""") + assert(columnName.quoteLess == "Has \"Quotes\"!") + } + + test("Unescaped quote throws an exception") { + assertThrows[IllegalArgumentException] { + ColumnReference("\"ab\"c\"") + } + } + + + test("ColumnReference object functionality") { + // index + val idx = ColumnReference(5) + assert(idx.isInstanceOf[ColumnReference.ColumnIndex], "Expected ColumnIndex for apply(5)") + assert(idx.asInstanceOf[ColumnReference.ColumnIndex].sqlEntry == "5") + } + + test("Equality and hashCode based on sqlEntry") { + val n1 = ColumnReference("ab") + val n2 = ColumnReference("\"ab\"") + assert(n1 == n2, "Expected equality based on sqlEntry") + assert(n1.hashCode == n2.hashCode, "Expected equal hashCode based on sqlEntry") + } + + test("Equality and hashCode based on sqlEntry for non-standard names") { + val n1 = ColumnReference("a-b") + val n2 = ColumnReference("\"a-b\"") + assert(n1 == n2, "Expected equality based on sqlEntry") + assert(n1.hashCode == n2.hashCode, "Expected equal hashCode based on sqlEntry") + } + + test("Equality for same column name per standard, just differently entered") { + val n1 = ColumnReference("ab_cd") + val n2 = ColumnReference("AB_CD") + val n3 = ColumnReference("\"ab_cd\"") + assert(n1 == n2) + assert(n1 == n3) + } + + test("Inequality for different sqlEntry") { + val n1 = ColumnReference("a-b") + val n2 = ColumnReference("a-B") + assert(n1 != n2, "Expected inequality for different sqlEntry") + } + + test("Indexed column reference different from number named column") { + val n1 = ColumnReference("1") + val n2 = ColumnReference(1) + assert(n1 != n2, "Expected inequality for different sqlEntry") + } + + test("ColumnReference.quote works correctly") { + assert(ColumnReference.quote("abc") == "\"abc\"") + assert(ColumnReference.quote("a\"bc") == "\"a\"bc\"") + assert(ColumnReference.quote("\"abc\"") == "\"\"abc\"\"") + } + + test("ColumnReference.escapeQuote works correctly") { + assert(ColumnReference.escapeQuote("abc") == "abc") + assert(ColumnReference.escapeQuote("a\"bc") == "a\"\"bc") + assert(ColumnReference.escapeQuote("\"abc\"") == "\"\"abc\"\"") + } + + test("ColumnReference.hasUnescapedQuotes works correctly") { + assert(ColumnReference.hasUnescapedQuotes("a\"bc")) + assert(!ColumnReference.hasUnescapedQuotes("abc")) + assert(ColumnReference.hasUnescapedQuotes("\"abc\"")) + assert(!ColumnReference.hasUnescapedQuotes("a\"\"bc\"\"de")) + assert(ColumnReference.hasUnescapedQuotes("a\"\"bc\"\"\"de")) + assert(!ColumnReference.hasUnescapedQuotes("a\"\"\"\"de")) + assert(ColumnReference.hasUnescapedQuotes("a\"\"\"\"\"de")) + } + +} diff --git a/mag/src/test/scala/za/co/absa/db/mag/core/SqlEntryCompositionUnitTests.scala b/mag/src/test/scala/za/co/absa/db/mag/core/SqlEntryCompositionUnitTests.scala new file mode 100644 index 0000000..1ae020e --- /dev/null +++ b/mag/src/test/scala/za/co/absa/db/mag/core/SqlEntryCompositionUnitTests.scala @@ -0,0 +1,96 @@ +/* + * Copyright 2026 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.core + +import org.scalatest.funsuite.AnyFunSuiteLike +import za.co.absa.db.mag.core.SqlEntryComposition._ + +class SqlEntryCompositionUnitTests extends AnyFunSuiteLike { + private val field1 = ColumnName("field1") + private val field2 = ColumnName("field2") + private val tableName = SqlEntry("my_table") + private val condition = SqlEntry("field1 = 100") + private val functionName = SqlEntry("my_function") + private val param = SqlEntry("42") + + test("Composition of `SELECT ... FROM`") { + val query = SELECT(field1, field2) FROM tableName + val expectedSql = "SELECT field1, field2 FROM my_table" + assert(query.sqlEntry.entry == expectedSql) + } + + test("Composition of `SELECT ... FROM ... WHERE ...`") { + val query = SELECT(ALL) FROM tableName WHERE condition + val expectedSql = "SELECT * FROM my_table WHERE field1 = 100" + assert(query.sqlEntry.entry == expectedSql) + } + test("Composition of `SELECT ... FROM ... ORDER BY`") { + val query = SELECT(field1) FROM tableName ORDER BY(field1, field2) + val expectedSql = "SELECT field1 FROM my_table ORDER BY field1, field2" + assert(query.sqlEntry.entry == expectedSql) + } + test("Composition of `SELECT ... FROM ... WHERE ... ORDER BY`") { + val query = SELECT(COUNT_ALL) FROM tableName WHERE condition ORDER BY(field1) + val expectedSql = "SELECT count(1) AS cnt FROM my_table WHERE field1 = 100 ORDER BY field1" + assert(query.sqlEntry.entry == expectedSql) + } + + test("Composition of `SELECT ... FROM ... WHERE ... ORDER BY` using function call") { + val query = SELECT(ALL) FROM functionName(param) WHERE condition ORDER BY(field1) + val expectedSql = "SELECT * FROM my_function(42) WHERE field1 = 100 ORDER BY field1" + assert(query.sqlEntry.entry == expectedSql) + } + + test("Composition of `SELECT ... FROM ...` using function call without providing parameters") { + val query = SELECT(ALL) FROM functionName() + val expectedSql = "SELECT * FROM my_function()" + assert(query.sqlEntry.entry == expectedSql) + } + + + test("Composition of `INSERT INTO ...`") { + val query = INSERT INTO tableName VALUES("100", "'Sample Text'") + val expectedSql = "INSERT INTO my_table VALUES(100, 'Sample Text')" + assert(query.sqlEntry.entry == expectedSql) + } + + test("Composition of `INSERT INTO ... VALUES RETURNING ...` not providing any field names") { + val query = INSERT INTO tableName VALUES(param) RETURNING(ALL) + val expectedSql = "INSERT INTO my_table VALUES(42) RETURNING *" + assert(query.sqlEntry.entry == expectedSql) + } + + test("Composition of `INSERT INTO ...(...) VALUES ... RETURNING ...` using field names") { + val fields = SqlEntry(field1, field2) + val query = INSERT INTO tableName(fields.entry) VALUES("100, 'Sample Text'") RETURNING(ALL) + val expectedSql = "INSERT INTO my_table(field1, field2) VALUES(100, 'Sample Text') RETURNING *" + assert(query.sqlEntry.entry == expectedSql) + } + + test("Composition of `DELETE ... FROM ...`") { + val query = DELETE FROM tableName + val expectedSql = "DELETE FROM my_table" + assert(query.sqlEntry.entry == expectedSql) + } + + test("Composition of `DELETE ... FROM ... RETURNING ...`") { + val query = DELETE FROM tableName RETURNING(field1) + val expectedSql = "DELETE FROM my_table RETURNING field1" + assert(query.sqlEntry.entry == expectedSql) + } + +} diff --git a/mag/src/test/scala/za/co/absa/db/mag/core/SqlEntryUnitTests.scala b/mag/src/test/scala/za/co/absa/db/mag/core/SqlEntryUnitTests.scala new file mode 100644 index 0000000..9ec6280 --- /dev/null +++ b/mag/src/test/scala/za/co/absa/db/mag/core/SqlEntryUnitTests.scala @@ -0,0 +1,35 @@ +/* + * Copyright 2026 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.core + +import org.scalatest.funsuite.AnyFunSuiteLike + +class SqlEntryUnitTests extends AnyFunSuiteLike { + test("SqlEntry addition with another SqlEntry works correctly") { + val entry1 = SqlEntry("CREATE TABLE test_table (id INT);") + val entry2 = SqlEntry("INSERT INTO test_table (id) VALUES (1);") + + val combinedEntry = entry1 + entry2 + assert(combinedEntry.entry == "CREATE TABLE test_table (id INT); INSERT INTO test_table (id) VALUES (1);") + } + + test("SqlEntry toString returns the `entry`` string") { + val sqlEntry = SqlEntry("SELECT * FROM test_table;") + assert(sqlEntry.toString == sqlEntry.entry) + } + +} diff --git a/mag/src/test/scala/za/co/absa/db/mag/implicits/MapImplicitsUnitTests.scala b/mag/src/test/scala/za/co/absa/db/mag/implicits/MapImplicitsUnitTests.scala new file mode 100644 index 0000000..c5b287d --- /dev/null +++ b/mag/src/test/scala/za/co/absa/db/mag/implicits/MapImplicitsUnitTests.scala @@ -0,0 +1,37 @@ +/* + * Copyright 2026 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.implicits + +import org.scalatest.funsuite.AnyFunSuite +import za.co.absa.db.mag.implicits.MapImplicits.MapEnhancements + +class MapImplicitsUnitTests extends AnyFunSuite { + + test("getOrThrow returns value when key exists") { + val map = Map("a" -> 1, "b" -> 2) + assert(map.getOrThrow("a", new NoSuchElementException) == 1) + } + + test("getOrThrow throws provided exception when key does not exist") { + val map = Map("a" -> 1, "b" -> 2) + val ex = new IllegalArgumentException("Key not found") + val thrown = intercept[IllegalArgumentException] { + map.getOrThrow("c", ex) + } + assert(thrown.getMessage == "Key not found") + } +} diff --git a/mag/src/test/scala/za/co/absa/db/mag/implicits/OptionImplicitsUnitTests.scala b/mag/src/test/scala/za/co/absa/db/mag/implicits/OptionImplicitsUnitTests.scala new file mode 100644 index 0000000..42150e5 --- /dev/null +++ b/mag/src/test/scala/za/co/absa/db/mag/implicits/OptionImplicitsUnitTests.scala @@ -0,0 +1,53 @@ +/* + * Copyright 2026 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.implicits + +import org.scalatest.funsuite.AnyFunSuite + +class OptionImplicitsUnitTests extends AnyFunSuite { + import OptionImplicits._ + + test("getOrThrow returns value when option is defined") { + val opt = Some(42) + assert(opt.getOrThrow(new NoSuchElementException) == 42) + } + + test("getOrThrow throws provided exception when option is None") { + val opt: Option[Int] = None + val ex = new IllegalArgumentException("No value present") + val thrown = intercept[IllegalArgumentException] { + opt.getOrThrow(ex) + } + assert(thrown.getMessage == "No value present") + } + + test("@= returns true when option contains the value") { + val opt = Some("hello") + assert(opt @= "hello") + } + + test("@= returns false when option does not contain the value") { + val opt = Some("world") + assert(!(opt @= "hello")) + } + + test("@= returns false when option is None") { + val opt: Option[String] = None + assert(!(opt @= "anything")) + } +} + diff --git a/mag/src/test/scala/za/co/absa/db/mag/naming/LettersCaseUnitTests.scala b/mag/src/test/scala/za/co/absa/db/mag/naming/LettersCaseUnitTests.scala new file mode 100644 index 0000000..6fffa46 --- /dev/null +++ b/mag/src/test/scala/za/co/absa/db/mag/naming/LettersCaseUnitTests.scala @@ -0,0 +1,33 @@ +/* + * Copyright 2026 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming + +import org.scalatest.funsuite.AnyFunSuiteLike + +class LettersCaseUnitTests extends AnyFunSuiteLike { + test("AsIs") { + assert(LettersCase.AsIs.convert("Hello World!") == "Hello World!") + } + + test("LowerCase") { + assert(LettersCase.LowerCase.convert("Hello World!") == "hello world!") + } + + test("UpperCase") { + assert(LettersCase.UpperCase.convert("Hello World!") == "HELLO WORLD!") + } +} diff --git a/mag/src/test/scala/za/co/absa/db/mag/naming/implementations/AsIsNamingUnitTests.scala b/mag/src/test/scala/za/co/absa/db/mag/naming/implementations/AsIsNamingUnitTests.scala new file mode 100644 index 0000000..69bbcd2 --- /dev/null +++ b/mag/src/test/scala/za/co/absa/db/mag/naming/implementations/AsIsNamingUnitTests.scala @@ -0,0 +1,36 @@ +/* + * Copyright 2026 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.matchers.should.Matchers +import za.co.absa.db.mag.naming.LettersCase + +class AsIsNamingUnitTests extends AnyFunSuiteLike with Matchers { + + val asIsNaming = new AsIsNaming(LettersCase.AsIs) + + test("AsIsNaming should return the same string") { + val input = "testString" + val expectedOutput = "testString" + + val output = asIsNaming.stringPerConvention(input) + + output shouldEqual expectedOutput + } + +} diff --git a/mag/src/test/scala/za/co/absa/db/mag/naming/implementations/ExplicitNamingRequiredUnitTests.scala b/mag/src/test/scala/za/co/absa/db/mag/naming/implementations/ExplicitNamingRequiredUnitTests.scala new file mode 100644 index 0000000..6a73e63 --- /dev/null +++ b/mag/src/test/scala/za/co/absa/db/mag/naming/implementations/ExplicitNamingRequiredUnitTests.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2026 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import za.co.absa.db.mag.exceptions.NamingException + +class ExplicitNamingRequiredUnitTests extends AnyWordSpec with Matchers { + private val explicitNamingRequired = new ExplicitNamingRequired() + + "stringPerConvention" should { + "fail" in { + intercept[NamingException] { + explicitNamingRequired.stringPerConvention("") + } + } + } + + "fromClassNamePerConvention" should { + "fail" in { + intercept[NamingException] { + explicitNamingRequired.fromClassNamePerConvention(this) + } + } + } +} diff --git a/mag/src/test/scala/za/co/absa/db/mag/naming/implementations/MapBasedNamingUnitTests.scala b/mag/src/test/scala/za/co/absa/db/mag/naming/implementations/MapBasedNamingUnitTests.scala new file mode 100644 index 0000000..20f4cc8 --- /dev/null +++ b/mag/src/test/scala/za/co/absa/db/mag/naming/implementations/MapBasedNamingUnitTests.scala @@ -0,0 +1,69 @@ +/* + * Copyright 2026 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import org.scalatest.funsuite.AnyFunSuiteLike +import za.co.absa.db.mag.exceptions.NamingException +import za.co.absa.db.mag.naming.LettersCase._ + +class MapBasedNamingUnitTests extends AnyFunSuiteLike { + private val map = Map( + "Hello" -> "World!", + "Foo" -> "Bar" + ) + + private val mapNamingConventionAsIs = MapBasedNaming(map) + private val mapNamingConventionLowerUpper = MapBasedNaming(map, LowerCase, UpperCase) + private val mapNamingConventionUpperLower = MapBasedNaming(map, UpperCase, LowerCase) + + test("MapBasedNaming with AsIs LetterCase should return the found string") { + + val input = "Hello" + val expectedOutput = "World!" + val output = mapNamingConventionAsIs.stringPerConvention(input) + + assert(output == expectedOutput) + } + + test("MapBasedNaming with altered case should return the found string in defined output case") { + val input1 = "Hello" + val expectedOutput1 = "WORLD!" + val output1 = mapNamingConventionLowerUpper.stringPerConvention(input1) + + assert(output1 == expectedOutput1) + + val input2 = "Foo" + val expectedOutput2 = "bar" + val output2 = mapNamingConventionUpperLower.stringPerConvention(input2) + + assert(output2 == expectedOutput2) + } + + test("MapBaseNaming fails when key is not found") { + val input = "NotInMap" + assertThrows[NamingException] { + mapNamingConventionAsIs.stringPerConvention(input) + } + } + + test("With default LetterCase MapBasedNaming cares about case") { + val input = "hello" + assertThrows[NamingException] { + mapNamingConventionAsIs.stringPerConvention(input) + } + } +} diff --git a/mag/src/test/scala/za/co/absa/db/mag/naming/implementations/SnakeCaseNamingUnitTests.scala b/mag/src/test/scala/za/co/absa/db/mag/naming/implementations/SnakeCaseNamingUnitTests.scala new file mode 100644 index 0000000..1b1ec71 --- /dev/null +++ b/mag/src/test/scala/za/co/absa/db/mag/naming/implementations/SnakeCaseNamingUnitTests.scala @@ -0,0 +1,53 @@ +/* + * Copyright 2026 ABSA Group Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package za.co.absa.db.mag.naming.implementations + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import za.co.absa.db.mag.naming.LettersCase._ + +class SnakeCaseNamingUnitTests extends AnyWordSpec with Matchers { + private class ThisIsATestClass + private val testInstance = new ThisIsATestClass() + + "stringPerConvention" should { + "handle empty string" in { + val nm = new SnakeCaseNaming(AsIs) + nm.stringPerConvention("") should be("") + } + } + + "fromClassNamePerConvention" should { + "return snake case" when { + "requested as is" in { + val nm = new SnakeCaseNaming(AsIs) + val result = nm.fromClassNamePerConvention(testInstance) + result should be("This_Is_A_Test_Class") + } + "requested as lowercase" in { + val nm = new SnakeCaseNaming(LowerCase) + val result = nm.fromClassNamePerConvention(testInstance) + result should be("this_is_a_test_class") + } + "requested as upper case" in { + val nm = new SnakeCaseNaming(UpperCase) + val result = nm.fromClassNamePerConvention(testInstance) + result should be("THIS_IS_A_TEST_CLASS") + } + } + } +}