Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,4 @@ build.log
.hg*

.bsp
.DS_Store
2 changes: 1 addition & 1 deletion jmf-rules.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.*#*
86 changes: 86 additions & 0 deletions mag/src/main/scala/za/co/absa/db/mag/core/ColumnReference.scala
Original file line number Diff line number Diff line change
@@ -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)
}
98 changes: 97 additions & 1 deletion mag/src/main/scala/za/co/absa/db/mag/core/SqlEntry.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}

}
}

}
146 changes: 146 additions & 0 deletions mag/src/main/scala/za/co/absa/db/mag/core/SqlEntryComposition.scala
Original file line number Diff line number Diff line change
@@ -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(", ")))
}
}

}
Original file line number Diff line number Diff line change
@@ -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)
Loading