From a15e5bfeada031c4be46e29d0f5c9ed91306a241 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 31 Mar 2025 17:04:16 -0400 Subject: [PATCH] Snowflake dialect --- .../src/main/kotlin/ktorm.base.gradle.kts | 8 +- .../src/main/kotlin/ktorm.publish.gradle.kts | 5 + .../org/ktorm/support/snowflake/BulkInsert.kt | 218 ++++++++ .../org/ktorm/support/snowflake/Functions.kt | 483 ++++++++++++++++++ .../ktorm/support/snowflake/InsertOrUpdate.kt | 117 +++++ .../snowflake/ListAggFunctionExpression.kt | 80 +++ .../ktorm/support/snowflake/NullExpression.kt | 31 ++ .../org/ktorm/support/snowflake/Subquery.kt | 83 +++ .../support/snowflake/SubqueryExpression.kt | 30 ++ .../support/snowflake/UpdateFromExpression.kt | 136 +++++ .../snowflake/UpdateFromStatementBuilder.kt | 41 ++ .../support/snowflake/WindowExpressions.kt | 97 ++++ .../src/main/moditect/module-info.java | 5 + .../ktorm/support/snowflake/FunctionsTest.kt | 97 ++++ .../ListAggFunctionExpressionTest.kt | 97 ++++ .../support/snowflake/SnowflakeDialectTest.kt | 126 +++++ .../support/snowflake/UpdateSnowflakeTest.kt | 56 ++ .../snowflake/WindowExpressionsTest.kt | 103 ++++ settings.gradle.kts | 3 + 19 files changed, 1812 insertions(+), 4 deletions(-) create mode 100644 ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/BulkInsert.kt create mode 100644 ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/Functions.kt create mode 100644 ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/InsertOrUpdate.kt create mode 100644 ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/ListAggFunctionExpression.kt create mode 100644 ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/NullExpression.kt create mode 100644 ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/Subquery.kt create mode 100644 ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/SubqueryExpression.kt create mode 100644 ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/UpdateFromExpression.kt create mode 100644 ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/UpdateFromStatementBuilder.kt create mode 100644 ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/WindowExpressions.kt create mode 100644 ktorm-support-snowflake/src/main/moditect/module-info.java create mode 100644 ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/FunctionsTest.kt create mode 100644 ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/ListAggFunctionExpressionTest.kt create mode 100644 ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/SnowflakeDialectTest.kt create mode 100644 ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/UpdateSnowflakeTest.kt create mode 100644 ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/WindowExpressionsTest.kt diff --git a/buildSrc/src/main/kotlin/ktorm.base.gradle.kts b/buildSrc/src/main/kotlin/ktorm.base.gradle.kts index 654dbfbf..e9e110c7 100644 --- a/buildSrc/src/main/kotlin/ktorm.base.gradle.kts +++ b/buildSrc/src/main/kotlin/ktorm.base.gradle.kts @@ -25,8 +25,8 @@ detekt { } java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } tasks { @@ -37,7 +37,7 @@ tasks { dependsOn(codegen) kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "11" allWarningsAsErrors = true freeCompilerArgs = listOf("-Xexplicit-api=strict") } @@ -45,7 +45,7 @@ tasks { compileTestKotlin { kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "11" } } diff --git a/buildSrc/src/main/kotlin/ktorm.publish.gradle.kts b/buildSrc/src/main/kotlin/ktorm.publish.gradle.kts index 61a3f47b..da5c6a34 100644 --- a/buildSrc/src/main/kotlin/ktorm.publish.gradle.kts +++ b/buildSrc/src/main/kotlin/ktorm.publish.gradle.kts @@ -164,6 +164,11 @@ publishing { name.set("hc224") email.set("hc224@pm.me") } + developer { + id.set("dmitchell") + name.set("Don Mitchell") + email.set("dhmitchell23@gmail.com") + } } } } diff --git a/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/BulkInsert.kt b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/BulkInsert.kt new file mode 100644 index 00000000..ee291293 --- /dev/null +++ b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/BulkInsert.kt @@ -0,0 +1,218 @@ +/* + * Copyright 2018-2025 the original author or authors. + * + * 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 org.ktorm.support.snowflake + +import org.ktorm.database.Database +import org.ktorm.dsl.AliasRemover +import org.ktorm.dsl.AssignmentsBuilder +import org.ktorm.dsl.KtormDsl +import org.ktorm.dsl.batchInsert +import org.ktorm.expression.ColumnAssignmentExpression +import org.ktorm.expression.FunctionExpression +import org.ktorm.expression.SqlExpression +import org.ktorm.expression.TableExpression +import org.ktorm.schema.BaseTable +import org.ktorm.schema.Column + +/** + * Bulk insert expression, represents a bulk insert statement in Snowflake. This is a copy of the MySQL version. + * Given that 4 modules copy this, it's probably worth moving this to a shared module. + * + * For example: + * + * ```sql + * insert into table (column1, column2) values (?, ?), (?, ?), (?, ?)... + * on duplicate key update ... + * ``` + * + * @property table the table to be inserted. + * @property assignments column assignments of the bulk insert statement. + * @property updateAssignments the updated column assignments while key conflict exists. + */ +public data class BulkInsertExpression( + val table: TableExpression, + val assignments: List>>, + val updateAssignments: List> = emptyList(), + override val isLeafNode: Boolean = false, + override val extraProperties: Map = emptyMap() +) : SqlExpression() + +/** + * Construct a bulk insert expression in the given closure, then execute it and return the effected row count. + * + * The usage is almost the same as [batchInsert], but this function is implemented by generating a special SQL + * using MySQL bulk insert syntax, instead of based on JDBC batch operations. For this reason, its performance + * is much better than [batchInsert]. + * + * The generated SQL is like: `insert into table (column1, column2) values (?, ?), (?, ?), (?, ?)...`. + * + * Usage: + * + * ```kotlin + * database.bulkInsert(Employees) { + * item { + * set(it.name, "jerry") + * set(it.job, "trainee") + * set(it.managerId, 1) + * set(it.hireDate, LocalDate.now()) + * set(it.salary, 50) + * set(it.departmentId, 1) + * } + * item { + * set(it.name, "linda") + * set(it.job, "assistant") + * set(it.managerId, 3) + * set(it.hireDate, LocalDate.now()) + * set(it.salary, 100) + * set(it.departmentId, 2) + * } + * } + * ``` + * + * @param table the table to be inserted. + * @param block the DSL block, extension function of [BulkInsertStatementBuilder], used to construct the expression. + * @return the effected row count. + * @see batchInsert + */ +public fun > Database.bulkInsert( + table: T, block: BulkInsertStatementBuilder.() -> Unit +): Int { + val builder = BulkInsertStatementBuilder(table).apply(block) + require(builder.assignments.isNotEmpty()) { "There are no items in the bulk operation." } + + val expression = dialect.createExpressionVisitor(AliasRemover).visit( + BulkInsertExpression(table.asExpression(), builder.assignments) + ) + + return executeUpdate(expression) +} + +/** + * Bulk insert records to the table, determining if there is a key conflict while inserting each of them, + * and automatically performs updates if any conflict exists. + * + * Usage: + * + * ```kotlin + * database.bulkInsertOrUpdate(Employees) { + * item { + * set(it.id, 1) + * set(it.name, "vince") + * set(it.job, "engineer") + * set(it.salary, 1000) + * set(it.hireDate, LocalDate.now()) + * set(it.departmentId, 1) + * } + * item { + * set(it.id, 5) + * set(it.name, "vince") + * set(it.job, "engineer") + * set(it.salary, 1000) + * set(it.hireDate, LocalDate.now()) + * set(it.departmentId, 1) + * } + * onDuplicateKey { + * set(it.salary, it.salary + 900) + * } + * } + * ``` + * + * Generated SQL: + * + * ```sql + * insert into t_employee (id, name, job, salary, hire_date, department_id) + * values (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?) + * on duplicate key update salary = salary + ? + * ``` + * + * @since 3.3.0 + * @param table the table to be inserted. + * @param block the DSL block used to construct the expression. + * @return the effected row count. + * @see bulkInsert + */ +public fun > Database.bulkInsertOrUpdate( + table: T, block: BulkInsertOrUpdateStatementBuilder.() -> Unit +): Int { + val builder = BulkInsertOrUpdateStatementBuilder(table).apply(block) + require(builder.assignments.isNotEmpty()) { "There are no items in the bulk operation." } + require(builder.assignments.none { it.isEmpty() }) { "There are no columns to insert in the statement." } + + val expression = dialect.createExpressionVisitor(AliasRemover).visit( + BulkInsertExpression(table.asExpression(), builder.assignments, builder.updateAssignments) + ) + + return executeUpdate(expression) +} + +/** + * DSL builder for bulk insert statements. + */ +@KtormDsl +public open class BulkInsertStatementBuilder>(internal val table: T) { + internal val assignments = ArrayList>>() + + /** + * Add the assignments of a new row to the bulk insert. + */ + public fun item(block: AssignmentsBuilder.(T) -> Unit) { + val builder = SnowflakeAssignmentsBuilder() + builder.block(table) + + require( + assignments.isEmpty() || + assignments[0].map { it.column.name } == builder.assignments.map { it.column.name } + ) { "Every item in a batch operation must be the same." } + assignments += builder.assignments + } +} + +/** + * DSL builder for bulk insert or update statements. + */ +@KtormDsl +public class BulkInsertOrUpdateStatementBuilder>(table: T) : BulkInsertStatementBuilder(table) { + internal val updateAssignments = ArrayList>() + + /** + * Specify the update assignments while any key conflict exists. + */ + public fun onDuplicateKey(block: BulkInsertOrUpdateOnDuplicateKeyClauseBuilder.(T) -> Unit) { + val builder = BulkInsertOrUpdateOnDuplicateKeyClauseBuilder() + builder.block(table) + updateAssignments += builder.assignments + } +} + +/** + * DSL builder for bulk insert or update on duplicate key clause. + */ +@KtormDsl +public class BulkInsertOrUpdateOnDuplicateKeyClauseBuilder : SnowflakeAssignmentsBuilder() { + + /** + * Use VALUES() function in a ON DUPLICATE KEY UPDATE clause. + */ + public fun values(column: Column): FunctionExpression { + // values(column) + return FunctionExpression( + functionName = "values", + arguments = listOf(column.asExpression()), + sqlType = column.sqlType + ) + } +} diff --git a/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/Functions.kt b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/Functions.kt new file mode 100644 index 00000000..8f95684a --- /dev/null +++ b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/Functions.kt @@ -0,0 +1,483 @@ +/* + * Copyright 2025 the original author or authors. + * + * 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. + * + * Original authors of the snowflake dialect were CarGurus: Don Mitchell, Ashish Shrestha, Mike Roberts, and others. + */ +package org.ktorm.support.snowflake + +import org.ktorm.expression.AggregateExpression +import org.ktorm.expression.AggregateType +import org.ktorm.expression.ArgumentExpression +import org.ktorm.expression.ColumnExpression +import org.ktorm.expression.FunctionExpression +import org.ktorm.schema.ColumnDeclaring +import org.ktorm.schema.DoubleSqlType +import org.ktorm.schema.IntSqlType +import org.ktorm.schema.LocalDateSqlType +import org.ktorm.schema.LocalDateTimeSqlType +import org.ktorm.schema.LongSqlType +import org.ktorm.schema.SqlType +import org.ktorm.schema.VarcharSqlType +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZonedDateTime + +public enum class DatePart(public val sql: String) { + SECOND("SECOND"), + DAY("DAY"), + MONTH("MONTH"), + YEAR("YEAR"), + WEEK("WEEK"), + QUARTER("QUARTER") +} + +public enum class TimePart(public val sql: String) { + HOUR("HOUR"), + MINUTE("MINUTE"), + SECOND("SECOND"), + MILLISECOND("MS"), + MICROSECOND("US"), + NANOSECOND("NS") +} + +// FIXME make it more generic than LocalDateTime +/** + * Sql function to convert a timestamp to a date. + * + * @return The function expression representing the to_date sql function. + */ +public fun ColumnDeclaring.toDate(): FunctionExpression { + return FunctionExpression( + functionName = "to_date", + arguments = listOf(this.asExpression()), + sqlType = LocalDateSqlType + ) +} + +@JvmName("stringToDate") +public fun ColumnDeclaring.toDate(): FunctionExpression { + return FunctionExpression( + functionName = "to_date", + arguments = listOf(this.asExpression()), + sqlType = LocalDateSqlType + ) +} + +private const val DATE_TRUNC = "date_trunc" + +// TODO make it more generic than LocalDateTime +/** + * Sql function to convert a truncate a date or timestamp to a particular day unit. + * + * @param part The date part to truncate to. + * + * @return The function expression representing the date_trunc sql function. + */ +public fun ColumnDeclaring.dateTrunc(part: DatePart): FunctionExpression { + return FunctionExpression( + functionName = DATE_TRUNC, + arguments = listOf( + ArgumentExpression(part.sql, VarcharSqlType), + this.asExpression() + ), + sqlType = LocalDateSqlType + ) +} + +/** + * Sql function to convert a truncate a date or timestamp to a particular day unit. + * + * @param part The date part to truncate to. + * + * @return The function expression representing the date_trunc sql function. + */ +@JvmName("dateTruncZonedDateTime") +public fun ColumnDeclaring.dateTrunc(part: DatePart): FunctionExpression { + return FunctionExpression( + functionName = DATE_TRUNC, + arguments = listOf( + ArgumentExpression(part.sql, VarcharSqlType), + this.asExpression() + ), + sqlType = LocalDateSqlType + ) +} + +/** + * Sql function to convert a truncate a date or timestamp to a particular time unit. + * + * @param part The time part to truncate to. + * + * @return The function expression representing the date_trunc sql function. + */ +public fun ColumnDeclaring.dateTrunc(part: TimePart): FunctionExpression { + return FunctionExpression( + functionName = DATE_TRUNC, + arguments = listOf( + ArgumentExpression(part.sql, VarcharSqlType), + this.asExpression() + ), + sqlType = LocalDateTimeSqlType + ) +} + +@JvmName("hourFromZonedDateTime") +public fun ColumnDeclaring.hour(): FunctionExpression { + return FunctionExpression( + functionName = "HOUR", + arguments = listOf( + this.asExpression() + ), + sqlType = IntSqlType + ) +} + +@JvmName("hourFromLocalDateTime") +public fun ColumnDeclaring.hour(): FunctionExpression { + return FunctionExpression( + functionName = "HOUR", + arguments = listOf( + this.asExpression() + ), + sqlType = IntSqlType + ) +} + +@JvmName("hourFromString") +public fun ColumnDeclaring.hour(): FunctionExpression { + return FunctionExpression( + functionName = "HOUR", + arguments = listOf( + this.asExpression() + ), + sqlType = IntSqlType + ) +} + +@JvmName("zonedDateTimeDiff") +public fun ColumnDeclaring.dateDiff(part: TimePart, end: ColumnDeclaring): + FunctionExpression { + return FunctionExpression( + functionName = "DATEDIFF", + arguments = listOf( + ArgumentExpression(part.sql, VarcharSqlType), + this.asExpression(), + end.asExpression() + ), + sqlType = IntSqlType + ) +} + +@JvmName("localDateTimeDiff") +public fun ColumnDeclaring.dateDiff(part: TimePart, end: ColumnDeclaring): + FunctionExpression { + return FunctionExpression( + functionName = "DATEDIFF", + arguments = listOf( + ArgumentExpression(part.sql, VarcharSqlType), + this.asExpression(), + end.asExpression() + ), + sqlType = IntSqlType + ) +} + +/** + * Sql function to add a unit of time to a LocalDate. + * + * @param part The time part to add. + * @param value The amount of timeparts to add to the date + * + * @return The function expression representing the dateadd sql function. + */ +public fun ColumnDeclaring.dateAdd(part: DatePart, value: Int): FunctionExpression { + return FunctionExpression( + functionName = "dateadd", + arguments = listOf( + ArgumentExpression(part.sql, VarcharSqlType), + ArgumentExpression(value, IntSqlType), + this.asExpression() + ), + sqlType = LocalDateSqlType + ) +} + +/** + * Sql function to add a unit of time to a [LocalDateTime]. + * + * @param part The time part to add. + * @param value The amount of timeparts to add to the date + * + * @return The function expression representing the dateadd sql function. + */ +public fun ColumnDeclaring.dateTimeAdd(part: DatePart, value: Int): FunctionExpression { + return FunctionExpression( + functionName = "dateadd", + arguments = listOf( + ArgumentExpression(part.sql, VarcharSqlType), + ArgumentExpression(value, IntSqlType), + this.asExpression() + ), + sqlType = LocalDateTimeSqlType + ) +} + +/** + * Sql function to decode a value into other values. + * + * @param T The class of the input values. + * @param U The class of the output values. + * @param decodings The mapping of input values to output values. + * @param default The default output value to use if the column value does not match any of the input value mappings. + * @param outputSqlType The sql type of the output. + * + * @return The function expression representing the decode sql function. + */ +public fun ColumnDeclaring.decode( + decodings: Map, + default: U?, + outputSqlType: SqlType +): FunctionExpression { + val column = this + var arguments = decodings.map { (input, output) -> + listOf>( + ArgumentExpression(input, column.sqlType), + ArgumentExpression(output, outputSqlType) + ) + }.flatten() + + default?.let { arguments += ArgumentExpression(default, outputSqlType) } + + return FunctionExpression( + "decode", + listOf(this.asExpression()) + arguments, + outputSqlType + ) +} + +/** + * Sql function to decode a value into other values. + * + * @param T The class of the input values. + * @param U The class of the output values. + * @param decodings The mapping of input values to output values. + * @param outputSqlType The sql type of the output. + * + * @return The function expression representing the decode sql function. + */ +public fun ColumnDeclaring.decode( + decodings: Map, + outputSqlType: SqlType +): FunctionExpression { + return this.decode(decodings, null, outputSqlType) +} + +/** + * Sql function to provide a default for a column value if it is null. + * + * @param T The class of the column values. + * @param default The default output value. + * + * @return The function expression representing the decode sql function. + */ +public fun ColumnDeclaring.nvl(default: T): FunctionExpression { + val column = this + val argument = ArgumentExpression(default, column.sqlType) + + return FunctionExpression("nvl", listOf(column.asExpression(), argument), column.sqlType) +} + +/** + * Strips the table from a column expression, for situations in which the table context can interfere with the + * SQL generation logic. + * @return The column expression without a table. + **/ +public fun ColumnExpression.stripTable(): ColumnExpression = this.copy(table = null) + +/** + * Sql function to provide a default for a column value if it is null. TO DO: be able to allow any scalar expression + * as a default. + * + * @param T The class of the column values. + * @param default The other column to use as a default value + * + * @return The function expression representing the decode sql function. + */ +public fun ColumnDeclaring.nvl(default: ColumnDeclaring): FunctionExpression { + val column = this + + return FunctionExpression("nvl", listOf(column.asExpression(), default.asExpression()), column.sqlType) +} + +/** + * Sql function to return a 64-bit hash (which is not the same as the xxhash algorithm used for ad extra hashing in + * SiteVisitorClicks). + * + * @return The function expression representing the hash sql function. + */ +public fun ColumnDeclaring.hash(): FunctionExpression { + return FunctionExpression("hash", listOf(this.asExpression()), LongSqlType) +} + +/** + * Sql function to return SHA2 hash. + * + * Default is a 256-bit hash, but Snowflake supports 224-, 256-, 384-, and 512-bit digest sizes. + * + * @return The function expression representing the sha2 sql function. + */ +public fun ColumnDeclaring.sha2(digestSize: Int = 256): FunctionExpression { + require(digestSize in supportedSha2DigestSizes) { + "$digestSize is not one of these Snowflake supported SHA2 digest sizes: " + + supportedSha2DigestSizes.joinToString() + } + return FunctionExpression( + "sha2", + listOf(this.asExpression(), ArgumentExpression(digestSize, IntSqlType)), + VarcharSqlType + ) +} + +private val supportedSha2DigestSizes = setOf(224, 256, 384, 512) + +/** + * Sql function to return the absolute value of a number. + * + * @return The function expression representing abs hash sql function. + */ +public fun ColumnDeclaring.abs(): FunctionExpression { + return FunctionExpression("abs", listOf(this.asExpression()), this.sqlType) +} + +/** + * Sql function to return the absolute value of a number. + * + * @return The function expression representing abs hash sql function. + */ +public fun ColumnDeclaring.floor(): FunctionExpression { + return FunctionExpression("floor", listOf(this.asExpression()), this.sqlType) +} + +/** + * Sql function to access the key value of an object or variant. + * + * @param T The class of the source column, which should represent an object or variant in snowflake. + * @param V The class of the attribute value. + * @param key The key to access. + * @param outputSqlType The sql type of the result, which should be a sql type of V + * + * @return The function expression representing the sql function that is the access of an object at a particular key + * value. + */ +public fun ColumnDeclaring.get( + key: String, + outputSqlType: SqlType +): FunctionExpression = FunctionExpression( + "get", listOf(this.asExpression(), ArgumentExpression(key, VarcharSqlType)), outputSqlType +) + +/** + * Sql function to convert the timezone of a date to a different region. + * + * @param region The IANA strings used for timezones, see https://nodatime.org/TimeZones + * + * @return The function expression representing abs hash sql function. + */ +public fun ColumnDeclaring.convertTimezone(region: ColumnDeclaring): FunctionExpression { + return FunctionExpression( + "convert_timezone", + listOf(region.asExpression(), this.asExpression()), + VarcharSqlType + ) +} + +public fun ColumnDeclaring.convertTimezone(targetTz: String): FunctionExpression { + return FunctionExpression( + functionName = "convert_timezone", + arguments = listOf( + ArgumentExpression(targetTz, VarcharSqlType), + this.asExpression() + ), + VarcharSqlType + ) +} + +public fun iff( + condition: ColumnDeclaring, + then: String, + otherwise: String +): FunctionExpression { + return FunctionExpression( + functionName = "iff", + arguments = listOf( + condition.asExpression(), + ArgumentExpression(then, VarcharSqlType), + ArgumentExpression(otherwise, VarcharSqlType) + ), + sqlType = VarcharSqlType + ) +} + +/** + * Sql function `concat(s1, s2, ...)`. + * + * @param values List of columns or string values + * @return the function representing `concat` of the arguments + */ +public fun concat(vararg values: Any): FunctionExpression { + val arguments = values.map { + when (it) { + is String -> ArgumentExpression(it, VarcharSqlType) + is Char -> ArgumentExpression(it.toString(), VarcharSqlType) + is ColumnDeclaring<*> -> it.asExpression() + else -> error("Unsupported argument for concat $it") + } + } + return FunctionExpression("concat", arguments, VarcharSqlType) +} + +/** + * Sql function to_char(val). + * + * @param value Column or integer/long/double + * @return the function representing the conversion of numerical field to string. + */ +public fun toChar(value: Any): FunctionExpression { + val argument = when (value) { + is Long -> ArgumentExpression(value, LongSqlType) + is Double -> ArgumentExpression(value, DoubleSqlType) + is Int -> ArgumentExpression(value, IntSqlType) + is ColumnDeclaring<*> -> value.asExpression() + else -> error("Unsupported argument for toChar $value") + } + return FunctionExpression("to_char", listOf(argument), VarcharSqlType) +} + +/** + * Sql function length(val). + * + * @param value String column + * @return the function representing the of characters in the string.git + */ +public fun length(value: ColumnDeclaring): FunctionExpression = + FunctionExpression("length", listOf(value.asExpression()), IntSqlType) + +/** + * Sql function count(val) + * The default ktorm implementation is int only + */ +public fun countLong(column: ColumnDeclaring<*>? = null): AggregateExpression { + return AggregateExpression(AggregateType.COUNT, column?.asExpression(), false, LongSqlType) +} diff --git a/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/InsertOrUpdate.kt b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/InsertOrUpdate.kt new file mode 100644 index 00000000..ba047483 --- /dev/null +++ b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/InsertOrUpdate.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2018-2025 the original author or authors. + * + * 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 org.ktorm.support.snowflake + +import org.ktorm.database.Database +import org.ktorm.dsl.AliasRemover +import org.ktorm.dsl.AssignmentsBuilder +import org.ktorm.dsl.KtormDsl +import org.ktorm.expression.ColumnAssignmentExpression +import org.ktorm.expression.SqlExpression +import org.ktorm.expression.TableExpression +import org.ktorm.schema.BaseTable +import java.util.Collections.emptyList +import java.util.Collections.emptyMap + +/** + * Insert or update expression, represents an insert statement with an + * `on duplicate key update` clause in Snowflake. It's a literal copy of the MySQL version. + * + * @property table the table to be inserted. + * @property assignments the inserted column assignments. + * @property updateAssignments the updated column assignments while any key conflict exists. + */ +public data class InsertOrUpdateExpression( + val table: TableExpression, + val assignments: List>, + val updateAssignments: List> = emptyList(), + override val isLeafNode: Boolean = false, + override val extraProperties: Map = emptyMap() +) : SqlExpression() + +/** + * Insert a record to the table, determining if there is a key conflict while it's being inserted, and automatically + * performs an update if any conflict exists. + * + * Usage: + * + * ```kotlin + * database.insertOrUpdate(Employees) { + * set(it.id, 1) + * set(it.name, "vince") + * set(it.job, "engineer") + * set(it.salary, 1000) + * set(it.hireDate, LocalDate.now()) + * set(it.departmentId, 1) + * onDuplicateKey { + * set(it.salary, it.salary + 900) + * } + * } + * ``` + * + * Generated SQL: + * + * ```sql + * insert into t_employee (id, name, job, salary, hire_date, department_id) values (?, ?, ?, ?, ?, ?) + * on duplicate key update salary = salary + ? + * ``` + * + * @since 2.7 + * @param table the table to be inserted. + * @param block the DSL block used to construct the expression. + * @return the effected row count. + */ +public fun > Database.insertOrUpdate( + table: T, block: InsertOrUpdateStatementBuilder.(T) -> Unit +): Int { + val builder = InsertOrUpdateStatementBuilder().apply { block(table) } + require(builder.assignments.isNotEmpty()) { "There are no columns to insert in the statement." } + + val expression = dialect.createExpressionVisitor(AliasRemover).visit( + InsertOrUpdateExpression(table.asExpression(), builder.assignments, builder.updateAssignments) + ) + + return executeUpdate(expression) +} + +/** + * Base class of MySQL DSL builders, provide basic functions used to build assignments for insert or update DSL. + */ +@KtormDsl +public open class SnowflakeAssignmentsBuilder : AssignmentsBuilder() { + + /** + * A getter that returns the readonly view of the built assignments list. + */ + internal val assignments: List> get() = _assignments +} + +/** + * DSL builder for insert or update statements. + */ +@KtormDsl +public class InsertOrUpdateStatementBuilder : SnowflakeAssignmentsBuilder() { + internal val updateAssignments = ArrayList>() + + /** + * Specify the update assignments while any key conflict exists. + */ + public fun onDuplicateKey(block: AssignmentsBuilder.() -> Unit) { + val builder = SnowflakeAssignmentsBuilder().apply(block) + updateAssignments += builder.assignments + } +} diff --git a/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/ListAggFunctionExpression.kt b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/ListAggFunctionExpression.kt new file mode 100644 index 00000000..da73693b --- /dev/null +++ b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/ListAggFunctionExpression.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2025 the original author or authors. + * + * 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. + * + * Original authors of the snowflake dialect were CarGurus: Don Mitchell, Ashish Shrestha, Mike Roberts, and others. + */ +package org.ktorm.support.snowflake + +import org.ktorm.expression.ArgumentExpression +import org.ktorm.expression.OrderByExpression +import org.ktorm.expression.ScalarExpression +import org.ktorm.expression.SqlExpression +import org.ktorm.schema.ColumnDeclaring +import org.ktorm.schema.SqlType +import org.ktorm.schema.VarcharSqlType + +/** + * List aggregation expression, which represents the aggregation of strings in multiple rows into + * a single record. This implementation only covers the grouping approach, and not the window function version. + * See [https://docs.snowflake.com/en/sql-reference/functions/listagg](listAgg) for more information. + * + * @property argument The string expression to concatenate, + * @property separator The separator used to deliminate the separate row components, defaults to nothing. + * @property isDistinct The distinct keyword used to deduplicate list elements. + * @property withinGroupExpression The WITHIN GROUP expressions used to order the list elements. + */ +public data class ListAggFunctionExpression( + val argument: ScalarExpression, + val separator: ScalarExpression?, + val isDistinct: Boolean = false, + val withinGroupExpression: WithinGroupExpression = WithinGroupExpression(), + override val sqlType: SqlType = VarcharSqlType, + override val isLeafNode: Boolean = false, + override val extraProperties: Map = emptyMap() +) : ScalarExpression() + +public data class WithinGroupExpression( + val orderBy: List = emptyList(), + override val isLeafNode: Boolean = false, + override val extraProperties: Map = emptyMap() +) : SqlExpression() + +/** + * Creates a list aggregation expression. + * + * @param expr The string expression (usually a varchar column or a derivative of varchar columns) + * @param separator The separator to use to separate list elements. Defaults to None, representing no separator. + * @param isDistinct If true, include the DISTINCT keyword which dedupes list elements. + * @return A list aggregation expression. + */ +public fun listAgg( + expr: ColumnDeclaring, + separator: String? = null, + isDistinct: Boolean = false +): ListAggFunctionExpression { + val separatorExpression = separator?.let { ArgumentExpression(it, VarcharSqlType) } + return ListAggFunctionExpression(expr.asExpression(), separatorExpression, isDistinct = isDistinct) +} + +/** + * Adds ordering to list aggregation expression. Uses the same type of ktorm expression used for other ORDER BY + * statements. + * + * @param orders The list of order by expressions. + * @return A new list aggregation expression. + */ +public fun ListAggFunctionExpression.orderBy(orders: Collection): ListAggFunctionExpression = copy( + withinGroupExpression = WithinGroupExpression(orders.toList()) +) diff --git a/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/NullExpression.kt b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/NullExpression.kt new file mode 100644 index 00000000..65c3f9d0 --- /dev/null +++ b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/NullExpression.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2025 the original author or authors. + * + * 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. + * + * Original authors of the snowflake dialect were CarGurus: Don Mitchell, Ashish Shrestha, Mike Roberts, and others. + */ +package org.ktorm.support.snowflake + +import org.ktorm.expression.* +import org.ktorm.schema.* + +public data class NullExpression( + override val sqlType: SqlType, + override val isLeafNode: Boolean = false, + override val extraProperties: Map = emptyMap() +) : ScalarExpression() + +public fun Column.nullValue(): ColumnDeclaringExpression { + return ColumnDeclaringExpression(NullExpression(sqlType), name) +} diff --git a/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/Subquery.kt b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/Subquery.kt new file mode 100644 index 00000000..70212c0e --- /dev/null +++ b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/Subquery.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2025 the original author or authors. + * + * 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. + * + * Original authors of the snowflake dialect were CarGurus: Don Mitchell, Ashish Shrestha, Mike Roberts, and others. + */ +package org.ktorm.support.snowflake + +import org.ktorm.dsl.* +import org.ktorm.expression.* +import org.ktorm.schema.* +import java.util.Collections.emptyMap + +public data class Subquery(val query: Query, val alias: String) + +/** + * contextualize a [ColumnDeclaring] to the [subquery]. + */ +public fun ColumnDeclaring.apropos(subquery: Subquery): SubqueryColumnDeclaringExpression { + val expr = when (this) { + is ColumnDeclaringExpression -> this + is Column -> this.aliased(label) + else -> this.aliased(null) + } + + return SubqueryColumnDeclaringExpression( + expression = expr, + subqueryName = subquery.alias + ) +} + +/** + * contextualize an `aliased column name` to the [subquery]. + */ +public fun String.apropos(sqlType: SqlType, subquery: Subquery): SubqueryColumnDeclaringExpression = + SubqueryColumnDeclaringExpression( + expression = ColumnDeclaringExpression( + ColumnExpression(null, this, sqlType), + this), + subqueryName = subquery.alias + ) + +/** + * Equal operator, translated to = in SQL. + */ +public infix fun SubqueryColumnDeclaringExpression<*>.eq(expr: ColumnDeclaring<*>): BinaryExpression { + return BinaryExpression(BinaryExpressionType.EQUAL, asExpression(), expr.asExpression(), BooleanSqlType) +} + +/** + * subquery column declaring expression, representing a column in that has been contextualized to a subquery. + */ +public data class SubqueryColumnDeclaringExpression( + val expression: ColumnDeclaringExpression, + val subqueryName: String, + override val sqlType: SqlType = expression.sqlType, + override val isLeafNode: Boolean = false, + override val extraProperties: Map = emptyMap() +) : ScalarExpression() + +/** + * Obtain the value of the column contextualized to a subquery. Returns null if the column is null or not present. + */ +public operator fun QueryRowSet.get(column: SubqueryColumnDeclaringExpression): C? { + for (index in 1..metaData.columnCount) { + if (metaData.getColumnLabel(index).equals(column.expression.declaredName, ignoreCase = true)) { + return column.sqlType.getResult(this, index) + } + } + + return null +} diff --git a/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/SubqueryExpression.kt b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/SubqueryExpression.kt new file mode 100644 index 00000000..64ca995a --- /dev/null +++ b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/SubqueryExpression.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2025 the original author or authors. + * + * 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. + * + * Original authors of the snowflake dialect were CarGurus: Don Mitchell, Ashish Shrestha, Mike Roberts, and others. + */ +package org.ktorm.support.snowflake + +import org.ktorm.expression.* + +public data class SubqueryExpression( + val type: JoinType, + val left: QuerySourceExpression, + val right: QueryExpression, + val condition: ScalarExpression, + val alias: String, + override val isLeafNode: Boolean = false, + override val extraProperties: Map = emptyMap() +) : QuerySourceExpression() diff --git a/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/UpdateFromExpression.kt b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/UpdateFromExpression.kt new file mode 100644 index 00000000..ab5fddb8 --- /dev/null +++ b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/UpdateFromExpression.kt @@ -0,0 +1,136 @@ +/* + * Copyright 2025 the original author or authors. + * + * 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. + * + * Original authors of the snowflake dialect were CarGurus: Don Mitchell, Ashish Shrestha, Mike Roberts, and others. + */ +package org.ktorm.support.snowflake + +import org.ktorm.database.Database +import org.ktorm.dsl.QuerySource +import org.ktorm.dsl.and +import org.ktorm.dsl.from +import org.ktorm.dsl.update +import org.ktorm.dsl.where +import org.ktorm.expression.ColumnAssignmentExpression +import org.ktorm.expression.ScalarExpression +import org.ktorm.expression.SqlExpression +import org.ktorm.expression.TableExpression +import org.ktorm.schema.BaseTable +import org.ktorm.schema.ColumnDeclaring + +/** + * Update from expression, represents the `update` statement in SQL with from statement to join to extra tables. + * Uses a syntax akin to Query expressions, with [execute] as the method that forces execution instead of map collection + * method. + * + * @property database the database + * @property table the table to be updated. + * @property fromTable the table to be joined to. + * @property assignments column assignments of the update statement. + * @property where the update condition. + */ +public data class UpdateFromExpression( + val database: Database, + val table: TableExpression, + val fromTable: TableExpression, + val assignments: List>, + val where: ScalarExpression? = null, + override val isLeafNode: Boolean = false, + override val extraProperties: Map = emptyMap() +) : SqlExpression() { + /** + * The method used to add a where clause to [UpdateFromExpression]. + * + * @param block The block with the boolean condition for the update from expression. Note that only one block is + * ever applied (so a [where] call will override a previous [where] call, you cannot chain them) + * and the join conditionals MUST be present. + * + * @return An update from expression. + */ + public fun where(block: () -> ColumnDeclaring): UpdateFromExpression { + return this.copy(where = block().asExpression()) + } + + /** + * Create a mutable list, then add filter conditions to the list in the given callback function, finally combine + * them with the [and] operator and set the combined condition as the `where` clause of this query. + * + * Note that if we don't add any conditions to the list, the `where` clause would not be set. + * + * @param block The block that will add boolean conditions to a list. This will replace the `where` clause of the + * update statement. + * + * @return An [UpdateFromExpression] with the conditions added within a compound [and] construction. + */ + public fun whereWithConditions(block: (MutableList>) -> Unit): UpdateFromExpression { + var conditions: List> = ArrayList>().apply(block) + if (conditions.isEmpty()) { + return this + } else { + val whereClause = conditions.reduce { acc, condition -> acc.and(condition) } + return this.copy(where = whereClause.asExpression()) + } + } + + /** + * The method used to execute the query, currently only method that can execute the query in [UpdateFromExpression]. + * + * @return The number of records updated. + */ + public fun execute(): Int = this.database.executeUpdate(this) +} + +/** + * The standard method to create an [UpdateFromExpression], in a manner similar to [Database.from] (for creating + * [QuerySource] expressions. Note that this uses the [UpdateStatmentBuilder] to specify the columns to set, but does + * not handle the [where] clause as is done in [Database.update]. Also unlike [Database.update], + * does not perform the update immediately. + * + * @param T The table class to update. + * @param U The table class to join to for the update. + * @param table The table to update. + * @param fromTable The table to join to for the update. + * @param block The block that sets the values for the columns to update. + * + * @return An update from expression. Note that unlike the base Database.update method, does not execute immediately. + */ +public fun , U : BaseTable<*>> Database.updateFrom( + table: T, + fromTable: U, + block: UpdateFromStatementBuilder.(T) -> Unit +): UpdateFromExpression { + return buildUpdateFromExpression( + this, + table, + fromTable, + block + ) +} + +private fun , U : BaseTable<*>> buildUpdateFromExpression( + database: Database, + table: T, + fromTable: U, + block: UpdateFromStatementBuilder.(T) -> Unit +): UpdateFromExpression { + val builder = UpdateFromStatementBuilder() + builder.block(table) + return UpdateFromExpression( + database, + table.asExpression(), + fromTable.asExpression(), + builder.assignments(), + ) +} diff --git a/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/UpdateFromStatementBuilder.kt b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/UpdateFromStatementBuilder.kt new file mode 100644 index 00000000..e62f7189 --- /dev/null +++ b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/UpdateFromStatementBuilder.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2025 the original author or authors. + * + * 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. + * + * Original authors of the snowflake dialect were CarGurus: Don Mitchell, Ashish Shrestha, Mike Roberts, and others. + */ +package org.ktorm.support.snowflake + +import org.ktorm.dsl.* +import org.ktorm.expression.ColumnAssignmentExpression +import org.ktorm.schema.* + +/** + * Builder for [UpdateFromExpression] statements. Use that class. This is a helper. + */ +@KtormDsl +public class UpdateFromStatementBuilder : AssignmentsBuilder() { + private var where: ColumnDeclaring? = null + + /** + * Specify the where clause for this update statement. + */ + public fun where(block: () -> ColumnDeclaring) { + this.where = block() + } + + public fun assignments(): List> { + return _assignments + } +} diff --git a/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/WindowExpressions.kt b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/WindowExpressions.kt new file mode 100644 index 00000000..7442fedb --- /dev/null +++ b/ktorm-support-snowflake/src/main/kotlin/org/ktorm/support/snowflake/WindowExpressions.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2025 the original author or authors. + * + * 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. + * + * Original authors of the snowflake dialect were CarGurus: Don Mitchell, Ashish Shrestha, Mike Roberts, and others. + */ +package org.ktorm.support.snowflake + +import org.ktorm.dsl.window +import org.ktorm.expression.ScalarExpression +import org.ktorm.expression.WindowFunctionExpression +import org.ktorm.expression.WindowFunctionType +import org.ktorm.expression.WindowSpecificationExpression +import org.ktorm.schema.SqlType + +public data class NullAwareWindowFunctionExpression( + val type: WindowFunctionType, + val arguments: List>, + val isDistinct: Boolean = false, + val window: WindowSpecificationExpression = WindowSpecificationExpression(), + override val sqlType: SqlType, + val handleNulls: NullHandling? = null, + override val isLeafNode: Boolean = false, + override val extraProperties: Map = emptyMap() +) : ScalarExpression() + +public enum class NullHandling { + IGNORE_NULLS, + RESPECT_NULLS +} + +// These are manually enumerated from the Snowflake docs. Ktorm doesn't seem to expose an easy way +// to build static type checks for this condition. +private val HANDLE_NULLS_WINDOW_TYPES: Set = setOf( + WindowFunctionType.FIRST_VALUE, + WindowFunctionType.LAG, + WindowFunctionType.LAST_VALUE, + WindowFunctionType.LEAD, + WindowFunctionType.NTH_VALUE, +) + +public fun NullAwareWindowFunctionExpression.over( + window: WindowSpecificationExpression = window() +): NullAwareWindowFunctionExpression { + return this.copy(window = window) +} + +/** + * Explicitly specify that the window function should ignore null values. + */ +@JvmName("last_value_ignore_nulls") +public fun WindowFunctionExpression.ignoreNulls(): NullAwareWindowFunctionExpression { + require(this.type in HANDLE_NULLS_WINDOW_TYPES) { + "Window function ${this.type} does not support IGNORE NULLS in Snowflake" + } + return NullAwareWindowFunctionExpression( + type = type, + arguments = arguments, + isDistinct = isDistinct, + window = window, + handleNulls = NullHandling.IGNORE_NULLS, + sqlType = sqlType, + isLeafNode = isLeafNode, + extraProperties = extraProperties, + ) +} + +/** + * Explicitly specify that the window function should respect null values. + */ +@JvmName("last_value_respect_nulls") +public fun WindowFunctionExpression.respectNulls(): NullAwareWindowFunctionExpression { + require(this.type in HANDLE_NULLS_WINDOW_TYPES) { + "Window function ${this.type} does not support RESPECT NULLS in Snowflake" + } + return NullAwareWindowFunctionExpression( + type = type, + arguments = arguments, + isDistinct = isDistinct, + window = window, + handleNulls = NullHandling.RESPECT_NULLS, + sqlType = sqlType, + isLeafNode = isLeafNode, + extraProperties = extraProperties, + ) +} diff --git a/ktorm-support-snowflake/src/main/moditect/module-info.java b/ktorm-support-snowflake/src/main/moditect/module-info.java new file mode 100644 index 00000000..00f6fee7 --- /dev/null +++ b/ktorm-support-snowflake/src/main/moditect/module-info.java @@ -0,0 +1,5 @@ +module ktorm.support.snowflake { + requires ktorm.core; + exports org.ktorm.support.snowflake; + provides org.ktorm.database.SqlDialect with org.ktorm.support.snowflake.SnowflakeDialect; +} diff --git a/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/FunctionsTest.kt b/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/FunctionsTest.kt new file mode 100644 index 00000000..5cde0171 --- /dev/null +++ b/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/FunctionsTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2025 the original author or authors. + * + * 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. + * + * Original authors of the snowflake dialect were CarGurus: Don Mitchell, Ashish Shrestha, Mike Roberts, and others. + */ +package org.ktorm.support.snowflake + +import org.junit.Before +import org.junit.Test +import org.ktorm.dsl.* +import org.ktorm.schema.* +import kotlin.random.Random +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class FunctionsTest { + private lateinit var database: org.ktorm.database.Database + + @Before + fun setUp() { + database = setupMockDatabase() + } + + @Test + fun testConcatJoinsStrings() { + val query = database.from(SampleTable) + .select(SampleTable.entryDate, concat(SampleTable.name, ", ", SampleTable.otherName)) + assertTrue(query.sql.contains("CONCAT(")) + assertTrue(query.sql.contains("?")) + assertTrue(query.sql.contains(SampleTable.name.name)) + assertTrue(query.sql.contains(SampleTable.otherName.name)) + } + + @Test + fun testGeneratesSha2() { + val query = database.from(SampleTable) + .select(SampleTable.name.sha2()) + + val sql = query.sql + assertTrue(sql.contains("SHA2") && sql.contains(",")) + } + + @Test + fun testRejectsUnsupportedDigestSizes() { + assertFailsWith { + database.from(SampleTable) + .select(SampleTable.name.sha2(12)) + } + } + + @Test + fun testDateAdd() { + val days = Random.nextInt() + val query = database.from(SampleTable) + .select( + SampleTable.entryDate.toDate().dateAdd(DatePart.DAY, days)) + assertTrue(query.sql.contains("DATEADD(?, ?, ")) + assertTrue(query.sql.contains("SAMPLE_TABLE.ENTRYDATE")) + } + + @Test + fun testUsesGetToAccessObjectElement() { + val query = database.from(SampleTable) + .select(SampleTable.jsonObject.get("externalId", LongSqlType)) + assertTrue(query.sql.contains("GET(")) + assertTrue(query.sql.contains("SAMPLE_TABLE.JSONOBJECT, ?)")) + } + + @Test + fun testHandlesToCharForNumericFields() { + val query = database.from(SampleTable) + .select(toChar(4L), toChar(6.3), toChar(10)) + + assertTrue(query.sql.contains("TO_CHAR(?), TO_CHAR(?), TO_CHAR(?)")) + } + + @Test + fun testHandlesToCharForColumns() { + val query = database.from(SampleTable) + .select(toChar(SampleTable.id)) + + assertTrue(query.sql.contains("TO_CHAR(WAREHOUSE.RANDOM.SAMPLE_TABLE.ID)")) + } +} + diff --git a/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/ListAggFunctionExpressionTest.kt b/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/ListAggFunctionExpressionTest.kt new file mode 100644 index 00000000..ed4d085e --- /dev/null +++ b/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/ListAggFunctionExpressionTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2025 the original author or authors. + * + * 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. + * + * Original authors of the snowflake dialect were CarGurus: Don Mitchell, Ashish Shrestha, Mike Roberts, and others. + */ +package org.ktorm.support.snowflake + +import org.junit.Before +import org.junit.Test +import org.ktorm.dsl.* +import kotlin.test.assertTrue + +/** + * Test class for the ListAgg function expressions in Snowflake SQL dialect. + * Tests the generation of LISTAGG SQL functions with various parameters. + */ +class ListAggFunctionExpressionTest { + private lateinit var database: org.ktorm.database.Database + + /** + * Sets up the test environment by creating a mock database connection. + */ + @Before + fun setUp() { + database = setupMockDatabase() + } + + /** + * Tests that the basic ListAgg query is correctly generated. + */ + @Test + fun testHandlesBaseQuery() { + val query = database.from(SampleTable) + .select(listAgg(SampleTable.name)) + + assertTrue(query.sql.contains("LISTAGG(WAREHOUSE.RANDOM.SAMPLE_TABLE.NAME)")) + assertFalse(query.sql.contains("WITHIN GROUP")) + } + + /** + * Tests that the ListAgg query with DISTINCT option is correctly generated. + */ + @Test + fun testHandlesDistinctValues() { + val query = database.from(SampleTable) + .select(listAgg(SampleTable.name, isDistinct = true)) + + assertTrue(query.sql.contains("LISTAGG(DISTINCT WAREHOUSE.RANDOM.SAMPLE_TABLE.NAME)")) + assertFalse(query.sql.contains("WITHIN GROUP")) + } + + /** + * Tests that the ListAgg query with a custom separator is correctly generated. + */ + @Test + fun testHandlesListSeparatorArguments() { + val query = database.from(SampleTable) + .select(listAgg(SampleTable.name, separator = ",")) + + assertTrue(query.sql.contains("LISTAGG(WAREHOUSE.RANDOM.SAMPLE_TABLE.NAME, ?)")) + assertFalse(query.sql.contains("WITHIN GROUP")) + } + + /** + * Tests that the ListAgg query with ORDER BY clause is correctly generated. + */ + @Test + fun testHandlesWithinGroupOrderingArguments() { + val query = database.from(SampleTable) + .select(listAgg(SampleTable.name) + .orderBy(listOf(SampleTable.id.asc(), SampleTable.endTimestamp.desc()))) + + assertTrue(query.sql.contains("LISTAGG(WAREHOUSE.RANDOM.SAMPLE_TABLE.NAME) WITHIN")) + assertTrue(query.sql.contains("WITHIN GROUP (ORDER BY WAREHOUSE.RANDOM.SAMPLE_TABLE.ID, ")) + assertTrue(query.sql.contains(", WAREHOUSE.RANDOM.SAMPLE_TABLE.endTimestamp DESC)")) + } + + /** + * Helper function to improve readability in tests. + */ + private fun assertFalse(condition: Boolean) { + kotlin.test.assertFalse(condition, "Condition should be false") + } +} + diff --git a/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/SnowflakeDialectTest.kt b/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/SnowflakeDialectTest.kt new file mode 100644 index 00000000..9a863dd4 --- /dev/null +++ b/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/SnowflakeDialectTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2025 the original author or authors. + * + * 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. + * + * Original authors of the snowflake dialect were CarGurus: Don Mitchell, Ashish Shrestha, Mike Roberts, and others. + */ +package org.ktorm.support.snowflake + +import io.mockk.every +import io.mockk.mockk +import org.ktorm.database.Database +import org.ktorm.logging.detectLoggerImplementation +import org.ktorm.schema.* +import java.sql.* +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +object SampleTable : Table(tableName = "SAMPLE_TABLE", schema = "RANDOM", catalog = "WAREHOUSE") { + val id = long("ID") + val name = varchar("NAME") + var entryDate = datetime("ENTRYDATE") + val otherName = varchar("OTHERNAME") + val startTimestamp = datetime("startTimestamp") + val endTimestamp = datetime("endTimestamp") + val jsonObject = varchar("JSONOBJECT") +} + +fun setupMockDatabase(): Database { + val connection = mockk(relaxUnitFun = true) + every { connection.close() } returns mockk() + every { connection.metaData } returns mockk() + + return Database.connect( + dialect = SnowflakeDialect(), + logger = detectLoggerImplementation(), + connector = fun(): Connection { return connection } + ) +} + +class SnowflakeDialectTest { + private lateinit var database: org.ktorm.database.Database + + @Before + fun setUp() { + database = setupMockDatabase() + } + + @Test + internal fun `handles sql for date_trunc`() { + val formatter = SnowflakeFormatter(database, false, 2) + formatter.visit(SampleTable.entryDate.dateTrunc(DatePart.DAY)) + assertEquals("DATE_TRUNC(?, WAREHOUSE.RANDOM.SAMPLE_TABLE.ENTRYDATE) ", formatter.sql) + } + + @Test + internal fun `handles sql for to_date`() { + val formatter = SnowflakeFormatter(database, false, 2) + formatter.visit(SampleTable.entryDate.toDate()) + assertEquals("TO_DATE(WAREHOUSE.RANDOM.SAMPLE_TABLE.ENTRYDATE) ", formatter.sql) + } + + @Test + internal fun `format hour sql`() { + val formatter = SnowflakeFormatter(database, false, 2) + formatter.visit(SampleTable.startTimestamp.hour()) + assertEquals("HOUR(WAREHOUSE.RANDOM.SAMPLE_TABLE.startTimestamp) ", formatter.sql) + } + + @Test + internal fun `format datediff snowflake sql`() { + val formatter = SnowflakeFormatter(database, false, 2) + formatter.visit(SampleTable.startTimestamp.dateDiff(TimePart.HOUR, SampleTable.endTimestamp)) + assertEquals("DATEDIFF(?, WAREHOUSE.RANDOM.SAMPLE_TABLE.startTimestamp, " + + "WAREHOUSE.RANDOM.SAMPLE_TABLE.endTimestamp) ", formatter.sql) + } + + @Test + internal fun `handles sql for decode without default`() { + val formatter = SnowflakeFormatter(database, false, 2) + formatter.visit(SampleTable.name.decode(mapOf("foo" to 1, "bar" to 2), IntSqlType)) + assertEquals("DECODE(WAREHOUSE.RANDOM.SAMPLE_TABLE.NAME, ?, ?, ?, ?) ", formatter.sql) + } + + @Test + internal fun `handles sql for decode with default`() { + val formatter = SnowflakeFormatter(database, false, 2) + formatter.visit(SampleTable.name.decode(mapOf("foo" to 1, "bar" to 2), 3, IntSqlType)) + assertEquals("DECODE(WAREHOUSE.RANDOM.SAMPLE_TABLE.NAME, ?, ?, ?, ?, ?) ", formatter.sql) + } + + @Test + internal fun `formats null value properly`() { + val formatter = SnowflakeFormatter(database, false, 2) + formatter.visit(NullExpression(IntSqlType)) + assertEquals("null ", formatter.sql) + } + + @Test + internal fun `handles sql for default as column`() { + val formatter = SnowflakeFormatter(database, false, 2) + formatter.visit(SampleTable.name.nvl(SampleTable.otherName)) + assertEquals( + "NVL(WAREHOUSE.RANDOM.SAMPLE_TABLE.NAME, WAREHOUSE.RANDOM.SAMPLE_TABLE.OTHERNAME) ", + formatter.sql + ) + } + + @Test + internal fun `handles sql for default as value`() { + val formatter = SnowflakeFormatter(database, false, 2) + formatter.visit(SampleTable.name.nvl("default")) + assertEquals("NVL(WAREHOUSE.RANDOM.SAMPLE_TABLE.NAME, ?) ", formatter.sql) + } +} diff --git a/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/UpdateSnowflakeTest.kt b/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/UpdateSnowflakeTest.kt new file mode 100644 index 00000000..8937b3c5 --- /dev/null +++ b/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/UpdateSnowflakeTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2025 the original author or authors. + * + * 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. + * + * Original authors of the snowflake dialect were CarGurus: Don Mitchell, Ashish Shrestha, Mike Roberts, and others. + */ +package org.ktorm.support.snowflake + +import org.junit.Test +import org.ktorm.dsl.* +import org.ktorm.schema.* +import kotlin.test.assertEquals + +object SampleUpdateTable : Table("OTHERTABLE", schema = "RANDOM") { + val id = long("ID").primaryKey() + val sampleName = varchar("SAMPLENAME") + val sampleId = long("SAMPLEID") +} + +class UpdateSnowflakeTest { + private val database = setupMockDatabase() + + @Test + fun testUpdateFromSqlGeneration() { + val expression = database.updateFrom( + SampleUpdateTable, + SampleTable + ) { + set(SampleUpdateTable.sampleId, SampleTable.id) + } + .where { + SampleTable.name eq SampleUpdateTable.sampleName + } + + val formatter = SnowflakeFormatter(database, false, 2) + formatter.visit(expression) + + val expectedSql = "update random.othertable from warehouse.random.sample_table " + + "set sampleid = warehouse.random.sample_table.id " + + "where warehouse.random.sample_table.name = random.othertable.samplename " + + assertEquals(expectedSql, formatter.sql.lowercase()) + } +} + diff --git a/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/WindowExpressionsTest.kt b/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/WindowExpressionsTest.kt new file mode 100644 index 00000000..9b11a3b2 --- /dev/null +++ b/ktorm-support-snowflake/src/test/kotlin/org/ktorm/support/snowflake/WindowExpressionsTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2025 the original author or authors. + * + * 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. + * + * Original authors of the snowflake dialect were CarGurus: Don Mitchell, Ashish Shrestha, Mike Roberts, and others. + */ +package org.ktorm.support.snowflake + +import org.junit.Before +import org.junit.Test +import org.ktorm.dsl.asc +import org.ktorm.dsl.cumeDist +import org.ktorm.dsl.from +import org.ktorm.dsl.lastValue +import org.ktorm.dsl.orderBy +import org.ktorm.dsl.partitionBy +import org.ktorm.dsl.select +import org.ktorm.dsl.window +import org.ktorm.expression.WindowFunctionType.LAST_VALUE +import org.ktorm.schema.VarcharSqlType +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WindowExpressionsTest { + private lateinit var database: org.ktorm.database.Database + + @Before + fun setUp() { + database = setupMockDatabase() + } + + @Test + fun testGeneratesIgnoreNulls() { + val query = database.from(SampleTable) + .select( + lastValue(SampleTable.name).ignoreNulls().over( + window().partitionBy(SampleTable.startTimestamp).orderBy(SampleTable.startTimestamp.asc()) + ) + ) + + val sql = query.sql.uppercase() + assertTrue(sql.contains("LAST_VALUE") && sql.contains("IGNORE NULLS")) + assertFalse(sql.contains(") OVER")) + } + + @Test + fun testGeneratesRespectNulls() { + val query = database.from(SampleTable) + .select( + lastValue(SampleTable.name).respectNulls().over( + window().partitionBy(SampleTable.startTimestamp).orderBy(SampleTable.startTimestamp.asc()) + ) + ) + + val sql = query.sql.uppercase() + assertTrue(sql.contains("LAST_VALUE") && sql.contains("RESPECT NULLS")) + assertFalse(sql.contains(") OVER")) + } + + @Test + fun testIgnoresNone() { + val query = database.from(SampleTable) + .select( + NullAwareWindowFunctionExpression( + LAST_VALUE, + listOf(SampleTable.name.asExpression()), + window = window().partitionBy(SampleTable.startTimestamp) + .orderBy(SampleTable.startTimestamp.asc()), + sqlType = VarcharSqlType, + handleNulls = null, + ) + ) + + val sql = query.sql.uppercase() + assertTrue(sql.contains(") OVER"), "Expected to find the window function") + assertFalse(sql.contains("IGNORE NULLS") || sql.contains("RESPECT NULLS")) + } + + @Test + fun testFlagsUnsupportedWindowTypes() { + assertFailsWith { + database.from(SampleTable) + .select( + cumeDist().ignoreNulls().over( + window().partitionBy(SampleTable.startTimestamp) + ) + ) + } + } +} + diff --git a/settings.gradle.kts b/settings.gradle.kts index 30cf9df7..dd3d1dae 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.gradle.enterprise") version "3.14.1" + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } include("ktorm-core") @@ -13,6 +14,7 @@ include("ktorm-ksp-spi") include("ktorm-support-mysql") include("ktorm-support-oracle") include("ktorm-support-postgresql") +include("ktorm-support-snowflake") include("ktorm-support-sqlite") include("ktorm-support-sqlserver") @@ -30,3 +32,4 @@ gradleEnterprise { } } } +include("ktorm-support-snowflake")