From 260d6492760542c74ace02114875c8295f89add2 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 20:18:37 +0200 Subject: [PATCH] feat(persistence): extend SqlWriter with functionArgumentsText --- .../kotlin/kestrel/persistence/SqlWriter.kt | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/src/main/kotlin/kestrel/persistence/SqlWriter.kt b/src/main/kotlin/kestrel/persistence/SqlWriter.kt index 667f031..3b1d682 100644 --- a/src/main/kotlin/kestrel/persistence/SqlWriter.kt +++ b/src/main/kotlin/kestrel/persistence/SqlWriter.kt @@ -485,11 +485,190 @@ object SqlWriter { is ParameterExpression -> "\$${expression.index}" } + /** STRING_AGG's `separator`/`orderBy` and DISTINCT are the only [FunctionExpression] fields beyond `argument`/`star` - every other aggregate falls through the `?: ""` exactly as before. */ + private fun functionArgumentsText(function: FunctionExpression): String { + if (function.star) return "*" + val distinctPrefix = if (function.distinct) "DISTINCT " else "" + val value = function.argument?.let { expression(it) } ?: "" + val separator = function.separator ?: return "$distinctPrefix$value" + val orderBy = if (function.orderBy.isNotEmpty()) { + " ORDER BY " + function.orderBy.joinToString(", ") { key -> expression(key.expression) + orderKeySuffix(key) } + } else "" + return "$distinctPrefix$value, ${expression(separator)}$orderBy" + } + + /** `DESC`/`NULLS FIRST`/`NULLS LAST` are omitted when unspecified, so a plain ascending key with the engine's own default null ordering dumps exactly as written, matching every other optional-clause writer in this file. */ + private fun orderKeySuffix(key: OrderKey): String { + val direction = if (key.descending) " DESC" else "" + val nulls = when (key.nullsFirst) { + true -> " NULLS FIRST" + false -> " NULLS LAST" + null -> "" + } + return direction + nulls + } + + /** Without this, a view with `COUNT(x) FILTER (WHERE ...)` would dump as plain `COUNT(x)` and silently drop the predicate on reopen. */ + private fun filterClauseText(function: FunctionExpression): String = + function.filter?.let { " FILTER (WHERE ${expression(it)})" } ?: "" + + private fun windowArgumentsText(window: WindowExpression): String { + if (window.star) return "*" + return listOfNotNull(window.argument, window.offset, window.defaultValue).joinToString(", ") { expression(it) } + } + + private fun overClauseText(window: WindowExpression): String { + val parts = mutableListOf() + if (window.partitionBy.isNotEmpty()) { + parts.add("PARTITION BY " + window.partitionBy.joinToString(", ") { expression(it) }) + } + if (window.orderBy.isNotEmpty()) { + parts.add( + "ORDER BY " + window.orderBy.joinToString(", ") { key -> expression(key.expression) + orderKeySuffix(key) } + ) + } + window.frame?.let { frame -> + val unit = when (frame.unit) { + FrameUnit.Rows -> "ROWS" + FrameUnit.Range -> "RANGE" + FrameUnit.Groups -> "GROUPS" + } + val exclude = when (frame.exclude) { + FrameExclude.NoOthers -> "" + FrameExclude.CurrentRow -> " EXCLUDE CURRENT ROW" + FrameExclude.Group -> " EXCLUDE GROUP" + FrameExclude.Ties -> " EXCLUDE TIES" + } + parts.add("$unit BETWEEN ${frameBoundText(frame.start)} AND ${frameBoundText(frame.end)}$exclude") + } + return parts.joinToString(" ") + } + + private fun frameBoundText(bound: FrameBound): String = when (bound.type) { + FrameBoundType.UnboundedPreceding -> "UNBOUNDED PRECEDING" + FrameBoundType.UnboundedFollowing -> "UNBOUNDED FOLLOWING" + FrameBoundType.CurrentRow -> "CURRENT ROW" + FrameBoundType.Preceding -> "${expression(bound.offset!!)} PRECEDING" + FrameBoundType.Following -> "${expression(bound.offset!!)} FOLLOWING" + } + + private fun queryText(query: QueryStatement): String = when (query) { + is SelectStatement -> selectText(query) + is SetOperationStatement -> setOperationText(query) + is WithQuery -> withQueryText(query) + } + + private fun setOperationText(statement: SetOperationStatement): String = buildString { + append(queryText(statement.left)) + append(" ") + append(setOperatorKeyword(statement.operator)) + append(" ") + append(queryText(statement.right)) + statement.orderBy?.let { append(orderByText(it)) } + statement.limit?.let { append(" LIMIT $it") } + statement.offset?.let { append(" OFFSET $it") } + } + + private fun setOperatorKeyword(operator: SetOperator): String = when (operator) { + SetOperator.Union -> "UNION" + SetOperator.UnionAll -> "UNION ALL" + SetOperator.Intersect -> "INTERSECT" + SetOperator.IntersectAll -> "INTERSECT ALL" + SetOperator.Except -> "EXCEPT" + SetOperator.ExceptAll -> "EXCEPT ALL" + } + + private fun withQueryText(query: WithQuery): String = buildString { + append("WITH ") + if (query.recursive) append("RECURSIVE ") + append(query.ctes.joinToString(", ") { cte -> + val columns = cte.columns?.let { "(${it.joinToString(", ")})" } ?: "" + "${cte.name}$columns AS (${queryText(cte.query)})" + }) + append(" ") + append(queryText(query.body)) + } + + private fun selectText(select: SelectStatement): String = buildString { + append("SELECT ") + if (select.distinct) { + append("DISTINCT ") + if (select.distinctOn.isNotEmpty()) { + append("ON (") + append(select.distinctOn.joinToString(", ") { expression(it) }) + append(") ") + } + } + append(projectionText(select.projection)) + append(" FROM ") + append(fromItemText(select.from)) + for (join in select.joins) { + append( + when (join.type) { + JoinType.Left -> " LEFT JOIN " + JoinType.Right -> " RIGHT JOIN " + JoinType.Full -> " FULL JOIN " + JoinType.Inner -> " INNER JOIN " + JoinType.Cross -> " CROSS JOIN " + } + ) + append(fromItemText(join.from)) + join.on?.let { append(" ON "); append(expression(it)) } + } + select.where?.let { append(" WHERE "); append(expression(it)) } + if (select.groupingSpec != null || select.groupBy.isNotEmpty()) { + append(" GROUP BY ") + append(groupByText(select.groupingSpec, select.groupBy)) + } + select.having?.let { append(" HAVING "); append(expression(it)) } + select.orderBy?.let { append(orderByText(it)) } + select.limit?.let { append(" LIMIT $it") } + select.offset?.let { append(" OFFSET $it") } + } + + private fun groupByText(spec: GroupingSpec?, groupBy: List): String = when (spec) { + null -> groupBy.joinToString(", ") { expression(it) } + is RollupSpec -> "ROLLUP (" + spec.expressions.joinToString(", ") { expression(it) } + ")" + is CubeSpec -> "CUBE (" + spec.expressions.joinToString(", ") { expression(it) } + ")" + is GroupingSetsSpec -> "GROUPING SETS (" + spec.sets.joinToString(", ") { groupingSetElementText(it) } + ")" + } + + private fun groupingSetElementText(set: List): String = + if (set.isEmpty()) "()" else "(" + set.joinToString(", ") { expression(it) } + ")" + + private fun orderByText(orderBy: OrderBy): String = + " ORDER BY " + orderBy.keys.joinToString(", ") { key -> expression(key.expression) + orderKeySuffix(key) } + + private fun fromItemText(item: FromItem): String = when (item) { + is TableSource -> item.name + (item.alias?.let { " AS $it" } ?: "") + is SubquerySource -> (if (item.lateral) "LATERAL " else "") + "(${queryText(item.select)}) AS ${item.alias}" + } + + private fun projectionText(projection: Projection): String = when (projection) { + is StarProjection -> "*" + is ExpressionProjection -> projection.items.joinToString(", ") { item -> + val base = expression(item.expression) + item.alias?.let { "$base AS $it" } ?: base + } + } + + /** + * DECIMAL/DATE values render as `CAST('...' AS type)` (not a bare quoted string) + * because column coercion is strict everywhere else in this engine (no implicit + * TEXT-to-DECIMAL/DATE on INSERT/UPDATE) - the CAST is what actually converts the + * text back on replay. The re-cast uses the value's own scale (already the column's + * declared scale, since it can only have gotten here via coercion or CAST) with a + * generous precision, so it reproduces the exact BigDecimal rather than rounding + * through a Double the way a bare numeric literal would. + */ private fun literal(value: Value): String = when (value) { is Value.IntegerValue -> value.value.toString() is Value.RealValue -> realText(value.value) is Value.TextValue -> "'" + value.value.replace("'", "''") + "'" is Value.BooleanValue -> value.value.toString() + is Value.DecimalValue -> + "CAST('${value.value.toPlainString()}' AS DECIMAL($LITERAL_DECIMAL_PRECISION, ${value.value.scale()}))" + is Value.DateValue -> "CAST('${value.value}' AS DATE)" is Value.NullValue -> "NULL" } @@ -497,4 +676,6 @@ object SqlWriter { val plain = value.toString() return if (plain.contains('E') || plain.contains('e')) BigDecimal(value).toPlainString() else plain } + + private const val LITERAL_DECIMAL_PRECISION = 38 }