From 78f6209175700489ffc6535023c8dce9b3ef2c30 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Thu, 6 Aug 2026 09:47:15 +0200 Subject: [PATCH 1/2] fix(compiler-plugin): apply the template rules to string concatenation Inside a TemplateBuilder lambda, `+` on strings now means what `$` means: literal operands are SQL text, every other operand is interpolated through t(), and nested concatenations are spliced into the parent so fragments and values keep their order. An interpolation concatenated with a literal is folded by the compiler into a single concatenation node whose first argument is the left-hand template. That argument is now told apart from a `${...}` expression by its source range: the arguments of a string literal all start after its opening quote, while the first operand of a `+` chain starts where the chain itself starts. A concatenated value of a type other than String is compiled to a String.plus call, which the transformer now rewrites into a concatenation and processes by the same rules, so the operand is parameterized rather than concatenated into the SQL text. An expression inside `${...}` yields a value, so its own interpolations and concatenations are left to Kotlin. This also covers `"... LIKE ${"%$name%"}"`, which resolved the inner interpolation and then interpolated the result, producing two values for one placeholder. --- .../plugin/StormTemplateIrTransformer.kt | 100 ++++++- .../plugin/StormTemplateIrTransformer.kt | 100 ++++++- .../plugin/StormTemplateIrTransformer.kt | 107 ++++++- .../kotlin/plugin/StormTemplatePluginTest.kt | 264 ++++++++++++++++++ .../st/orm/template/CompilerPluginTest.kt | 32 +++ 5 files changed, 588 insertions(+), 15 deletions(-) diff --git a/storm-compiler-plugin/src/main/kotlin-transformer-2.0/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt b/storm-compiler-plugin/src/main/kotlin-transformer-2.0/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt index 1941d2f47..7e2148085 100644 --- a/storm-compiler-plugin/src/main/kotlin-transformer-2.0/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt +++ b/storm-compiler-plugin/src/main/kotlin-transformer-2.0/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt @@ -5,6 +5,7 @@ package st.orm.kotlin.plugin import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder import org.jetbrains.kotlin.ir.builders.irCall +import org.jetbrains.kotlin.ir.builders.irConcat import org.jetbrains.kotlin.ir.builders.irGet import org.jetbrains.kotlin.ir.declarations.IrFile import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction @@ -34,6 +35,11 @@ import org.jetbrains.kotlin.name.Name * * Expressions already wrapped in `t()` or `insert()` are left unchanged, so explicit usage remains valid. * + * The `+` operator on strings follows the same rules, so `"SELECT $col, " + "COUNT(*)"` yields the same template as + * `"SELECT $col, COUNT(*)"`: literal operands are SQL text and every other operand is interpolated. An expression + * inside an interpolation yields a value rather than SQL, so its own interpolations and concatenations are left to + * Kotlin: `"... LIKE ${"%" + name + "%"}"` interpolates a single string. + * * Example transformation: * * Source: @@ -53,8 +59,16 @@ class StormTemplateIrTransformer( companion object { private val TEMPLATE_CONTEXT_FQN = FqName("st.orm.template.TemplateContext") private val TEMPLATE_CONTEXT_CLASS_ID = ClassId(FqName("st.orm.template"), Name.identifier("TemplateContext")) + private val STRING_FQN = FqName("kotlin.String") } + /** + * Tracks whether the expression being visited contributes SQL text. Text position covers the statements of a + * TemplateBuilder lambda; the arguments of a string template (`${...}`) are value position, because they yield + * bind values rather than SQL. + */ + private var textPosition: Boolean = false + /** * Tracks the `TemplateContext` receiver parameter when we're inside a TemplateBuilder lambda, so we can generate * `receiver.t(expr)` calls. Null when outside such a lambda. @@ -91,7 +105,9 @@ class StormTemplateIrTransformer( } // We're inside a TemplateBuilder lambda. Set the receiver so nested visits can use it. val previousReceiver = templateContextReceiver + val previousTextPosition = textPosition templateContextReceiver = extensionReceiver + textPosition = true // Resolve function symbols if not already cached. if (tFunctionSymbol == null) { tFunctionSymbol = resolveTFunction() @@ -106,21 +122,65 @@ class StormTemplateIrTransformer( injectAutoInterpolationCall(function, extensionReceiver, autoInterpolation) } templateContextReceiver = previousReceiver + textPosition = previousTextPosition return result } override fun visitStringConcatenation(expression: IrStringConcatenation): IrExpression { - val receiver = templateContextReceiver ?: return super.visitStringConcatenation(expression) - val tFunction = tFunctionSymbol ?: return super.visitStringConcatenation(expression) - // We're inside a TemplateBuilder lambda and found a string template. Wrap each non-constant argument in t(). - // First, recursively transform each argument so that nested TemplateBuilder lambdas (e.g., inside subquery + if (templateContextReceiver == null || tFunctionSymbol == null) { + return super.visitStringConcatenation(expression) + } + if (!textPosition) { + // Value position: the string is computed as an ordinary Kotlin expression and interpolated as a single + // bind value, so its own interpolations are left alone. Nested TemplateBuilder lambdas are still visited. + return super.visitStringConcatenation(expression) + } + return processConcatenation(expression, isOperatorConcatenation(expression)) + } + + override fun visitCall(expression: IrCall): IrExpression { + val tFunction = tFunctionSymbol + if (templateContextReceiver == null || tFunction == null || !textPosition || !isStringPlus(expression)) { + return super.visitCall(expression) + } + // `a + b` in text position means the same as "$a$b", so rewrite it into a concatenation and apply the + // template rules to its operands. Nested `+` calls become concatenations in turn and are flattened below. + val operands = listOfNotNull(expression.dispatchReceiver, expression.getValueArgument(0)) + if (operands.size != 2) { + return super.visitCall(expression) + } + val builder = DeclarationIrBuilder(pluginContext, tFunction.symbol, expression.startOffset, expression.endOffset) + val concatenation = builder.irConcat() + concatenation.arguments.addAll(operands) + return processConcatenation(concatenation, operatorConcatenation = true) + } + + /** + * Applies the template rules to the arguments of [expression]: literal text stays a fragment and every other + * argument is wrapped in a `t()` call. + * + * When [operatorConcatenation] is true, the node represents a `+` chain rather than a single string literal. Its + * arguments are operands that contribute SQL text, so they are visited in text position and nested concatenations + * are spliced in rather than interpolated as a value. The arguments of a string literal are `${...}` expressions, + * which are visited in value position. + */ + private fun processConcatenation( + expression: IrStringConcatenation, + operatorConcatenation: Boolean, + ): IrExpression { + val receiver = templateContextReceiver ?: return expression + val tFunction = tFunctionSymbol ?: return expression + // Recursively transform each argument first, so that nested TemplateBuilder lambdas (e.g., inside subquery // calls) are processed before we wrap the argument in t(). val newArguments = expression.arguments.flatMap { argument -> - val transformed = argument.transform(this, null) + val transformed = transformInPosition(argument, operatorConcatenation) when { transformed is IrConst<*> && isFragment(transformed) -> listOf(transformed) transformed is IrConst<*> && hasMergedConstant(transformed) -> splitMergedConstant(transformed, receiver, tFunction) + // A literal operand of a `+` chain is SQL text, like the literal part of a string template. + transformed is IrConst<*> && operatorConcatenation -> listOf(transformed) + transformed is IrStringConcatenation && operatorConcatenation -> transformed.arguments.toList() isAlreadyWrappedInT(transformed) -> listOf(transformed) else -> listOf(wrapInT(transformed, receiver, tFunction)) } @@ -130,6 +190,36 @@ class StormTemplateIrTransformer( return expression } + /** Transforms [expression] with [textPosition] set for the duration of the visit. */ + private fun transformInPosition(expression: IrExpression, textPosition: Boolean): IrExpression { + val previousTextPosition = this.textPosition + this.textPosition = textPosition + val result = expression.transform(this, null) + this.textPosition = previousTextPosition + return result + } + + /** + * Checks whether an [IrStringConcatenation] represents a `+` chain rather than a single string literal. + * + * The compiler folds `"a${b}" + "c"` into one concatenation node whose arguments are the operands, which is + * indistinguishable in shape from the arguments of a string literal. The source range tells them apart: the + * arguments of a string literal all start after its opening quote, while the first operand of a `+` chain starts + * where the chain itself starts. + */ + private fun isOperatorConcatenation(expression: IrStringConcatenation): Boolean { + val first = expression.arguments.firstOrNull() ?: return false + if (expression.startOffset < 0 || first.startOffset < 0) return false + return first.startOffset <= expression.startOffset + } + + /** Checks whether a call is `String.plus`, the desugared form of the `+` operator on strings. */ + private fun isStringPlus(expression: IrCall): Boolean { + if (expression.symbol.owner.name.asString() != "plus") return false + val receiverType = expression.dispatchReceiver?.type ?: return false + return receiverType.classFqName == STRING_FQN + } + /** * Checks whether an [IrConst] is a string template fragment (literal SQL text) as opposed to an interpolated * constant expression like `${"value"}`. diff --git a/storm-compiler-plugin/src/main/kotlin-transformer-2.1/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt b/storm-compiler-plugin/src/main/kotlin-transformer-2.1/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt index d873b0217..4074d3004 100644 --- a/storm-compiler-plugin/src/main/kotlin-transformer-2.1/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt +++ b/storm-compiler-plugin/src/main/kotlin-transformer-2.1/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt @@ -5,6 +5,7 @@ package st.orm.kotlin.plugin import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder import org.jetbrains.kotlin.ir.builders.irCall +import org.jetbrains.kotlin.ir.builders.irConcat import org.jetbrains.kotlin.ir.builders.irGet import org.jetbrains.kotlin.ir.declarations.IrFile import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction @@ -34,6 +35,11 @@ import org.jetbrains.kotlin.name.Name * * Expressions already wrapped in `t()` or `insert()` are left unchanged, so explicit usage remains valid. * + * The `+` operator on strings follows the same rules, so `"SELECT $col, " + "COUNT(*)"` yields the same template as + * `"SELECT $col, COUNT(*)"`: literal operands are SQL text and every other operand is interpolated. An expression + * inside an interpolation yields a value rather than SQL, so its own interpolations and concatenations are left to + * Kotlin: `"... LIKE ${"%" + name + "%"}"` interpolates a single string. + * * Example transformation: * * Source: @@ -53,8 +59,16 @@ class StormTemplateIrTransformer( companion object { private val TEMPLATE_CONTEXT_FQN = FqName("st.orm.template.TemplateContext") private val TEMPLATE_CONTEXT_CLASS_ID = ClassId(FqName("st.orm.template"), Name.identifier("TemplateContext")) + private val STRING_FQN = FqName("kotlin.String") } + /** + * Tracks whether the expression being visited contributes SQL text. Text position covers the statements of a + * TemplateBuilder lambda; the arguments of a string template (`${...}`) are value position, because they yield + * bind values rather than SQL. + */ + private var textPosition: Boolean = false + /** * Tracks the `TemplateContext` receiver parameter when we're inside a TemplateBuilder lambda, so we can generate * `receiver.t(expr)` calls. Null when outside such a lambda. @@ -91,7 +105,9 @@ class StormTemplateIrTransformer( } // We're inside a TemplateBuilder lambda. Set the receiver so nested visits can use it. val previousReceiver = templateContextReceiver + val previousTextPosition = textPosition templateContextReceiver = extensionReceiver + textPosition = true // Resolve function symbols if not already cached. if (tFunctionSymbol == null) { tFunctionSymbol = resolveTFunction() @@ -106,21 +122,65 @@ class StormTemplateIrTransformer( injectAutoInterpolationCall(function, extensionReceiver, autoInterpolation) } templateContextReceiver = previousReceiver + textPosition = previousTextPosition return result } override fun visitStringConcatenation(expression: IrStringConcatenation): IrExpression { - val receiver = templateContextReceiver ?: return super.visitStringConcatenation(expression) - val tFunction = tFunctionSymbol ?: return super.visitStringConcatenation(expression) - // We're inside a TemplateBuilder lambda and found a string template. Wrap each non-constant argument in t(). - // First, recursively transform each argument so that nested TemplateBuilder lambdas (e.g., inside subquery + if (templateContextReceiver == null || tFunctionSymbol == null) { + return super.visitStringConcatenation(expression) + } + if (!textPosition) { + // Value position: the string is computed as an ordinary Kotlin expression and interpolated as a single + // bind value, so its own interpolations are left alone. Nested TemplateBuilder lambdas are still visited. + return super.visitStringConcatenation(expression) + } + return processConcatenation(expression, isOperatorConcatenation(expression)) + } + + override fun visitCall(expression: IrCall): IrExpression { + val tFunction = tFunctionSymbol + if (templateContextReceiver == null || tFunction == null || !textPosition || !isStringPlus(expression)) { + return super.visitCall(expression) + } + // `a + b` in text position means the same as "$a$b", so rewrite it into a concatenation and apply the + // template rules to its operands. Nested `+` calls become concatenations in turn and are flattened below. + val operands = listOfNotNull(expression.dispatchReceiver, expression.getValueArgument(0)) + if (operands.size != 2) { + return super.visitCall(expression) + } + val builder = DeclarationIrBuilder(pluginContext, tFunction.symbol, expression.startOffset, expression.endOffset) + val concatenation = builder.irConcat() + concatenation.arguments.addAll(operands) + return processConcatenation(concatenation, operatorConcatenation = true) + } + + /** + * Applies the template rules to the arguments of [expression]: literal text stays a fragment and every other + * argument is wrapped in a `t()` call. + * + * When [operatorConcatenation] is true, the node represents a `+` chain rather than a single string literal. Its + * arguments are operands that contribute SQL text, so they are visited in text position and nested concatenations + * are spliced in rather than interpolated as a value. The arguments of a string literal are `${...}` expressions, + * which are visited in value position. + */ + private fun processConcatenation( + expression: IrStringConcatenation, + operatorConcatenation: Boolean, + ): IrExpression { + val receiver = templateContextReceiver ?: return expression + val tFunction = tFunctionSymbol ?: return expression + // Recursively transform each argument first, so that nested TemplateBuilder lambdas (e.g., inside subquery // calls) are processed before we wrap the argument in t(). val newArguments = expression.arguments.flatMap { argument -> - val transformed = argument.transform(this, null) + val transformed = transformInPosition(argument, operatorConcatenation) when { transformed is IrConst && isFragment(transformed) -> listOf(transformed) transformed is IrConst && hasMergedConstant(transformed) -> splitMergedConstant(transformed, receiver, tFunction) + // A literal operand of a `+` chain is SQL text, like the literal part of a string template. + transformed is IrConst && operatorConcatenation -> listOf(transformed) + transformed is IrStringConcatenation && operatorConcatenation -> transformed.arguments.toList() isAlreadyWrappedInT(transformed) -> listOf(transformed) else -> listOf(wrapInT(transformed, receiver, tFunction)) } @@ -130,6 +190,36 @@ class StormTemplateIrTransformer( return expression } + /** Transforms [expression] with [textPosition] set for the duration of the visit. */ + private fun transformInPosition(expression: IrExpression, textPosition: Boolean): IrExpression { + val previousTextPosition = this.textPosition + this.textPosition = textPosition + val result = expression.transform(this, null) + this.textPosition = previousTextPosition + return result + } + + /** + * Checks whether an [IrStringConcatenation] represents a `+` chain rather than a single string literal. + * + * The compiler folds `"a${b}" + "c"` into one concatenation node whose arguments are the operands, which is + * indistinguishable in shape from the arguments of a string literal. The source range tells them apart: the + * arguments of a string literal all start after its opening quote, while the first operand of a `+` chain starts + * where the chain itself starts. + */ + private fun isOperatorConcatenation(expression: IrStringConcatenation): Boolean { + val first = expression.arguments.firstOrNull() ?: return false + if (expression.startOffset < 0 || first.startOffset < 0) return false + return first.startOffset <= expression.startOffset + } + + /** Checks whether a call is `String.plus`, the desugared form of the `+` operator on strings. */ + private fun isStringPlus(expression: IrCall): Boolean { + if (expression.symbol.owner.name.asString() != "plus") return false + val receiverType = expression.dispatchReceiver?.type ?: return false + return receiverType.classFqName == STRING_FQN + } + /** * Checks whether an [IrConst] is a string template fragment (literal SQL text) as opposed to an interpolated * constant expression like `${"value"}`. diff --git a/storm-compiler-plugin/src/main/kotlin-transformer-2.2/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt b/storm-compiler-plugin/src/main/kotlin-transformer-2.2/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt index d4834de3c..56ace39ac 100644 --- a/storm-compiler-plugin/src/main/kotlin-transformer-2.2/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt +++ b/storm-compiler-plugin/src/main/kotlin-transformer-2.2/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt @@ -5,6 +5,7 @@ package st.orm.kotlin.plugin import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder import org.jetbrains.kotlin.ir.builders.irCall +import org.jetbrains.kotlin.ir.builders.irConcat import org.jetbrains.kotlin.ir.builders.irGet import org.jetbrains.kotlin.ir.declarations.IrFile import org.jetbrains.kotlin.ir.declarations.IrParameterKind @@ -37,6 +38,11 @@ import org.jetbrains.kotlin.name.Name * * Expressions already wrapped in `t()` or `insert()` are left unchanged, so explicit usage remains valid. * + * The `+` operator on strings follows the same rules, so `"SELECT $col, " + "COUNT(*)"` yields the same template as + * `"SELECT $col, COUNT(*)"`: literal operands are SQL text and every other operand is interpolated. An expression + * inside an interpolation yields a value rather than SQL, so its own interpolations and concatenations are left to + * Kotlin: `"... LIKE ${"%" + name + "%"}"` interpolates a single string. + * * Example transformation: * * Source: @@ -56,8 +62,16 @@ class StormTemplateIrTransformer( companion object { private val TEMPLATE_CONTEXT_FQN = FqName("st.orm.template.TemplateContext") private val TEMPLATE_CONTEXT_CLASS_ID = ClassId(FqName("st.orm.template"), Name.identifier("TemplateContext")) + private val STRING_FQN = FqName("kotlin.String") } + /** + * Tracks whether the expression being visited contributes SQL text. Text position covers the statements of a + * TemplateBuilder lambda; the arguments of a string template (`${...}`) are value position, because they yield + * bind values rather than SQL. + */ + private var textPosition: Boolean = false + /** * Tracks the `TemplateContext` receiver parameter when we're inside a TemplateBuilder lambda, so we can generate * `receiver.t(expr)` calls. Null when outside such a lambda. @@ -96,7 +110,9 @@ class StormTemplateIrTransformer( } // We're inside a TemplateBuilder lambda. Set the receiver so nested visits can use it. val previousReceiver = templateContextReceiver + val previousTextPosition = textPosition templateContextReceiver = extensionReceiver + textPosition = true // Resolve function symbols if not already cached. if (tFunctionSymbol == null) { tFunctionSymbol = resolveTFunction() @@ -111,21 +127,65 @@ class StormTemplateIrTransformer( injectAutoInterpolationCall(function, extensionReceiver, autoInterpolation) } templateContextReceiver = previousReceiver + textPosition = previousTextPosition return result } override fun visitStringConcatenation(expression: IrStringConcatenation): IrExpression { - val receiver = templateContextReceiver ?: return super.visitStringConcatenation(expression) - val tFunction = tFunctionSymbol ?: return super.visitStringConcatenation(expression) - // We're inside a TemplateBuilder lambda and found a string template. Wrap each non-constant argument in t(). - // First, recursively transform each argument so that nested TemplateBuilder lambdas (e.g., inside subquery + if (templateContextReceiver == null || tFunctionSymbol == null) { + return super.visitStringConcatenation(expression) + } + if (!textPosition) { + // Value position: the string is computed as an ordinary Kotlin expression and interpolated as a single + // bind value, so its own interpolations are left alone. Nested TemplateBuilder lambdas are still visited. + return super.visitStringConcatenation(expression) + } + return processConcatenation(expression, isOperatorConcatenation(expression)) + } + + override fun visitCall(expression: IrCall): IrExpression { + val tFunction = tFunctionSymbol + if (templateContextReceiver == null || tFunction == null || !textPosition || !isStringPlus(expression)) { + return super.visitCall(expression) + } + // `a + b` in text position means the same as "$a$b", so rewrite it into a concatenation and apply the + // template rules to its operands. Nested `+` calls become concatenations in turn and are flattened below. + val operands = listOfNotNull(expression.dispatchReceiver, valueArgument(expression)) + if (operands.size != 2) { + return super.visitCall(expression) + } + val builder = DeclarationIrBuilder(pluginContext, tFunction.symbol, expression.startOffset, expression.endOffset) + val concatenation = builder.irConcat() + concatenation.arguments.addAll(operands) + return processConcatenation(concatenation, operatorConcatenation = true) + } + + /** + * Applies the template rules to the arguments of [expression]: literal text stays a fragment and every other + * argument is wrapped in a `t()` call. + * + * When [operatorConcatenation] is true, the node represents a `+` chain rather than a single string literal. Its + * arguments are operands that contribute SQL text, so they are visited in text position and nested concatenations + * are spliced in rather than interpolated as a value. The arguments of a string literal are `${...}` expressions, + * which are visited in value position. + */ + private fun processConcatenation( + expression: IrStringConcatenation, + operatorConcatenation: Boolean, + ): IrExpression { + val receiver = templateContextReceiver ?: return expression + val tFunction = tFunctionSymbol ?: return expression + // Recursively transform each argument first, so that nested TemplateBuilder lambdas (e.g., inside subquery // calls) are processed before we wrap the argument in t(). val newArguments = expression.arguments.flatMap { argument -> - val transformed = argument.transform(this, null) + val transformed = transformInPosition(argument, operatorConcatenation) when { transformed is IrConst && isFragment(transformed) -> listOf(transformed) transformed is IrConst && hasMergedConstant(transformed) -> splitMergedConstant(transformed, receiver, tFunction) + // A literal operand of a `+` chain is SQL text, like the literal part of a string template. + transformed is IrConst && operatorConcatenation -> listOf(transformed) + transformed is IrStringConcatenation && operatorConcatenation -> transformed.arguments.toList() isAlreadyWrappedInT(transformed) -> listOf(transformed) else -> listOf(wrapInT(transformed, receiver, tFunction)) } @@ -135,6 +195,43 @@ class StormTemplateIrTransformer( return expression } + /** Transforms [expression] with [textPosition] set for the duration of the visit. */ + private fun transformInPosition(expression: IrExpression, textPosition: Boolean): IrExpression { + val previousTextPosition = this.textPosition + this.textPosition = textPosition + val result = expression.transform(this, null) + this.textPosition = previousTextPosition + return result + } + + /** + * Checks whether an [IrStringConcatenation] represents a `+` chain rather than a single string literal. + * + * The compiler folds `"a${b}" + "c"` into one concatenation node whose arguments are the operands, which is + * indistinguishable in shape from the arguments of a string literal. The source range tells them apart: the + * arguments of a string literal all start after its opening quote, while the first operand of a `+` chain starts + * where the chain itself starts. + */ + private fun isOperatorConcatenation(expression: IrStringConcatenation): Boolean { + val first = expression.arguments.firstOrNull() ?: return false + if (expression.startOffset < 0 || first.startOffset < 0) return false + return first.startOffset <= expression.startOffset + } + + /** Checks whether a call is `String.plus`, the desugared form of the `+` operator on strings. */ + private fun isStringPlus(expression: IrCall): Boolean { + if (expression.symbol.owner.name.asString() != "plus") return false + val receiverType = expression.dispatchReceiver?.type ?: return false + return receiverType.classOrNull?.owner?.fqNameWhenAvailable == STRING_FQN + } + + /** Returns the single regular argument of a call, or null when the call takes a different shape. */ + private fun valueArgument(expression: IrCall): IrExpression? { + val index = expression.symbol.owner.parameters.indexOfFirst { it.kind == IrParameterKind.Regular } + if (index < 0) return null + return expression.arguments.getOrNull(index) + } + /** * Checks whether an [IrConst] is a string template fragment (literal SQL text) as opposed to an interpolated * constant expression like `${"value"}`. diff --git a/storm-compiler-plugin/src/test/kotlin/st/orm/kotlin/plugin/StormTemplatePluginTest.kt b/storm-compiler-plugin/src/test/kotlin/st/orm/kotlin/plugin/StormTemplatePluginTest.kt index 0ea4c6b4c..c58900aa6 100644 --- a/storm-compiler-plugin/src/test/kotlin/st/orm/kotlin/plugin/StormTemplatePluginTest.kt +++ b/storm-compiler-plugin/src/test/kotlin/st/orm/kotlin/plugin/StormTemplatePluginTest.kt @@ -619,6 +619,270 @@ class StormTemplatePluginTest { assertEquals("[1, 2, 3]", lines[1]) } + // -- String concatenation (+) tests -- + + @Test + fun `interpolation concatenated with a literal is auto-wrapped`() { + val source = SourceFile.kotlin( + "Test.kt", + """ + import st.orm.template.* + + fun main() { + val country = "NL" + val builder: TemplateBuilder = { "SELECT ${'$'}{country}, " + "COUNT(*) FROM users" } + val result = builder.build() + println(result.fragments.joinToString("|")) + println(result.values.joinToString(",")) + } + """, + ) + val result = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val output = result.runMain() + val lines = output.lines() + assertEquals("SELECT |, COUNT(*) FROM users", lines[0]) + assertEquals("NL", lines[1]) + } + + @Test + fun `literal concatenated with an interpolation is auto-wrapped`() { + val source = SourceFile.kotlin( + "Test.kt", + """ + import st.orm.template.* + + fun main() { + val id = 42 + val builder: TemplateBuilder = { "SELECT * FROM users " + "WHERE id = ${'$'}id" } + val result = builder.build() + println(result.fragments.joinToString("|")) + println(result.values.joinToString(",")) + } + """, + ) + val result = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val output = result.runMain() + val lines = output.lines() + assertEquals("SELECT * FROM users WHERE id = |", lines[0]) + assertEquals("42", lines[1]) + } + + @Test + fun `value concatenated with a literal is auto-wrapped`() { + val source = SourceFile.kotlin( + "Test.kt", + """ + import st.orm.template.* + + fun main() { + val id = 42 + val builder: TemplateBuilder = { "SELECT * FROM users WHERE id = " + id } + val result = builder.build() + println(result.fragments.joinToString("|")) + println(result.values.joinToString(",")) + } + """, + ) + val result = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val output = result.runMain() + val lines = output.lines() + assertEquals("SELECT * FROM users WHERE id = |", lines[0]) + assertEquals("42", lines[1]) + } + + @Test + fun `value between literals is auto-wrapped`() { + val source = SourceFile.kotlin( + "Test.kt", + """ + import st.orm.template.* + + fun main() { + val id = 42 + val builder: TemplateBuilder = { "SELECT * FROM users WHERE id = " + id + " ORDER BY name" } + val result = builder.build() + println(result.fragments.joinToString("|")) + println(result.values.joinToString(",")) + } + """, + ) + val result = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val output = result.runMain() + val lines = output.lines() + assertEquals("SELECT * FROM users WHERE id = | ORDER BY name", lines[0]) + assertEquals("42", lines[1]) + } + + @Test + fun `concatenated literals stay a single fragment`() { + val source = SourceFile.kotlin( + "Test.kt", + """ + import st.orm.template.* + + fun main() { + val builder: TemplateBuilder = { "SELECT COUNT(*) " + "FROM users" } + val result = builder.build() + println(result.fragments.joinToString("|")) + println(result.values.size) + } + """, + ) + val result = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val output = result.runMain() + val lines = output.lines() + assertEquals("SELECT COUNT(*) FROM users", lines[0]) + assertEquals("0", lines[1]) + } + + @Test + fun `concatenated interpolations are auto-wrapped in order`() { + val source = SourceFile.kotlin( + "Test.kt", + """ + import st.orm.template.* + + fun main() { + val id = 42 + val status = "active" + val builder: TemplateBuilder = { "SELECT * FROM users WHERE id = ${'$'}id" + " AND status = ${'$'}status" } + val result = builder.build() + println(result.fragments.joinToString("|")) + println(result.values.joinToString(",")) + } + """, + ) + val result = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val output = result.runMain() + val lines = output.lines() + assertEquals("SELECT * FROM users WHERE id = | AND status = |", lines[0]) + assertEquals("42,active", lines[1]) + } + + @Test + fun `explicit t() concatenated with a literal is not double-wrapped`() { + val source = SourceFile.kotlin( + "Test.kt", + """ + import st.orm.template.* + + fun main() { + val id = 42 + val builder: TemplateBuilder = { "SELECT * FROM users WHERE id = " + t(id) + " ORDER BY name" } + val result = builder.build() + println(result.fragments.joinToString("|")) + println(result.values.joinToString(",")) + } + """, + ) + val result = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val output = result.runMain() + val lines = output.lines() + assertEquals("SELECT * FROM users WHERE id = | ORDER BY name", lines[0]) + assertEquals("42", lines[1]) + } + + @Test + fun `concatenation in a conditional branch is auto-wrapped`() { + val source = SourceFile.kotlin( + "Test.kt", + """ + import st.orm.template.* + + fun main() { + val id = 42 + val builder: TemplateBuilder = { + if (id > 0) "SELECT * FROM users WHERE id = " + id else "SELECT * FROM users" + } + val result = builder.build() + println(result.fragments.joinToString("|")) + println(result.values.joinToString(",")) + } + """, + ) + val result = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val output = result.runMain() + val lines = output.lines() + assertEquals("SELECT * FROM users WHERE id = |", lines[0]) + assertEquals("42", lines[1]) + } + + @Test + fun `concatenation inside an interpolation stays a single value`() { + val source = SourceFile.kotlin( + "Test.kt", + """ + import st.orm.template.* + + fun main() { + val name = "Alice" + val builder: TemplateBuilder = { "SELECT * FROM users WHERE name LIKE ${'$'}{"%" + name + "%"}" } + val result = builder.build() + println(result.fragments.joinToString("|")) + println(result.values.joinToString(",")) + } + """, + ) + val result = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val output = result.runMain() + val lines = output.lines() + assertEquals("SELECT * FROM users WHERE name LIKE |", lines[0]) + assertEquals("%Alice%", lines[1]) + } + + @Test + fun `nested template inside an interpolation stays a single value`() { + val source = SourceFile.kotlin( + "Test.kt", + """ + import st.orm.template.* + + fun main() { + val name = "Alice" + val builder: TemplateBuilder = { "SELECT * FROM users WHERE name LIKE ${'$'}{"%${'$'}name%"}" } + val result = builder.build() + println(result.fragments.joinToString("|")) + println(result.values.joinToString(",")) + } + """, + ) + val result = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val output = result.runMain() + val lines = output.lines() + assertEquals("SELECT * FROM users WHERE name LIKE |", lines[0]) + assertEquals("%Alice%", lines[1]) + } + + @Test + fun `concatenation outside TemplateBuilder is not rewritten`() { + val source = SourceFile.kotlin( + "Test.kt", + """ + import st.orm.template.* + + fun main() { + val id = 42 + val regular = "id is " + id + println(regular) + } + """, + ) + val result = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val output = result.runMain() + assertEquals("id is 42", output) + } + // -- Multi-dollar string interpolation ($$) tests -- @Test diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/CompilerPluginTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/CompilerPluginTest.kt index f4e229019..26d9ae4e0 100644 --- a/storm-kotlin/src/test/kotlin/st/orm/template/CompilerPluginTest.kt +++ b/storm-kotlin/src/test/kotlin/st/orm/template/CompilerPluginTest.kt @@ -126,6 +126,38 @@ open class CompilerPluginTest( owners[0].lastName shouldBe "Franklin" } + @Test + fun `concatenated template and literal are auto-wrapped`() { + val cityId = 1 + val city = orm.query { "SELECT ${City::class} FROM ${City::class} " + "WHERE id = $cityId" } + .getSingleResult(City::class) + city.name shouldBe "Sun Paririe" + } + + @Test + fun `value concatenated with a literal is auto-wrapped`() { + val cityId = 2 + val city = orm.query { "SELECT ${City::class} FROM ${City::class} WHERE id = " + cityId } + .getSingleResult(City::class) + city.name shouldBe "Madison" + } + + @Test + fun `element concatenated with a literal is auto-wrapped`() { + val cities = orm.query { "SELECT " + City::class + " FROM " + City::class } + .getResultList(City::class) + cities shouldHaveSize 6 + } + + @Test + fun `concatenation inside an interpolation stays a single bind value`() { + val namePart = "adiso" + val cities = orm.query { "SELECT ${City::class} FROM ${City::class} WHERE name LIKE ${"%" + namePart + "%"}" } + .getResultList(City::class) + cities shouldHaveSize 1 + cities[0].id shouldBe 2 + } + @Test fun `unsafe element is auto-wrapped in t() and inlined as raw SQL`() { val cities = orm.query { "SELECT ${City::class} FROM ${City::class} WHERE ${unsafe("name = 'Madison'")}" } From 5a91823706d6eade7fb5ab757242fc4886a14f49 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Thu, 6 Aug 2026 12:06:49 +0200 Subject: [PATCH 2/2] fix(compiler-plugin): interpolate non-string constants in a concatenation A `+` chain treated every constant operand as SQL text, so `"WHERE id = " + 42` inlined the number while `"WHERE id = ${42}"` produced a bind value. Only a string literal is text; any other constant is interpolated, so both forms agree. --- .../plugin/StormTemplateIrTransformer.kt | 5 ++-- .../plugin/StormTemplateIrTransformer.kt | 5 ++-- .../plugin/StormTemplateIrTransformer.kt | 5 ++-- .../kotlin/plugin/StormTemplatePluginTest.kt | 23 +++++++++++++++++++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/storm-compiler-plugin/src/main/kotlin-transformer-2.0/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt b/storm-compiler-plugin/src/main/kotlin-transformer-2.0/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt index 7e2148085..165528023 100644 --- a/storm-compiler-plugin/src/main/kotlin-transformer-2.0/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt +++ b/storm-compiler-plugin/src/main/kotlin-transformer-2.0/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt @@ -178,8 +178,9 @@ class StormTemplateIrTransformer( transformed is IrConst<*> && isFragment(transformed) -> listOf(transformed) transformed is IrConst<*> && hasMergedConstant(transformed) -> splitMergedConstant(transformed, receiver, tFunction) - // A literal operand of a `+` chain is SQL text, like the literal part of a string template. - transformed is IrConst<*> && operatorConcatenation -> listOf(transformed) + // A string literal operand of a `+` chain is SQL text, like the literal part of a string template. + // Any other constant is interpolated, so that `+ 42` and `${42}` produce the same bind value. + transformed is IrConst<*> && operatorConcatenation && transformed.value is String -> listOf(transformed) transformed is IrStringConcatenation && operatorConcatenation -> transformed.arguments.toList() isAlreadyWrappedInT(transformed) -> listOf(transformed) else -> listOf(wrapInT(transformed, receiver, tFunction)) diff --git a/storm-compiler-plugin/src/main/kotlin-transformer-2.1/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt b/storm-compiler-plugin/src/main/kotlin-transformer-2.1/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt index 4074d3004..c747b3fbc 100644 --- a/storm-compiler-plugin/src/main/kotlin-transformer-2.1/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt +++ b/storm-compiler-plugin/src/main/kotlin-transformer-2.1/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt @@ -178,8 +178,9 @@ class StormTemplateIrTransformer( transformed is IrConst && isFragment(transformed) -> listOf(transformed) transformed is IrConst && hasMergedConstant(transformed) -> splitMergedConstant(transformed, receiver, tFunction) - // A literal operand of a `+` chain is SQL text, like the literal part of a string template. - transformed is IrConst && operatorConcatenation -> listOf(transformed) + // A string literal operand of a `+` chain is SQL text, like the literal part of a string template. + // Any other constant is interpolated, so that `+ 42` and `${42}` produce the same bind value. + transformed is IrConst && operatorConcatenation && transformed.value is String -> listOf(transformed) transformed is IrStringConcatenation && operatorConcatenation -> transformed.arguments.toList() isAlreadyWrappedInT(transformed) -> listOf(transformed) else -> listOf(wrapInT(transformed, receiver, tFunction)) diff --git a/storm-compiler-plugin/src/main/kotlin-transformer-2.2/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt b/storm-compiler-plugin/src/main/kotlin-transformer-2.2/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt index 56ace39ac..02aa70f76 100644 --- a/storm-compiler-plugin/src/main/kotlin-transformer-2.2/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt +++ b/storm-compiler-plugin/src/main/kotlin-transformer-2.2/st/orm/kotlin/plugin/StormTemplateIrTransformer.kt @@ -183,8 +183,9 @@ class StormTemplateIrTransformer( transformed is IrConst && isFragment(transformed) -> listOf(transformed) transformed is IrConst && hasMergedConstant(transformed) -> splitMergedConstant(transformed, receiver, tFunction) - // A literal operand of a `+` chain is SQL text, like the literal part of a string template. - transformed is IrConst && operatorConcatenation -> listOf(transformed) + // A string literal operand of a `+` chain is SQL text, like the literal part of a string template. + // Any other constant is interpolated, so that `+ 42` and `${42}` produce the same bind value. + transformed is IrConst && operatorConcatenation && transformed.value is String -> listOf(transformed) transformed is IrStringConcatenation && operatorConcatenation -> transformed.arguments.toList() isAlreadyWrappedInT(transformed) -> listOf(transformed) else -> listOf(wrapInT(transformed, receiver, tFunction)) diff --git a/storm-compiler-plugin/src/test/kotlin/st/orm/kotlin/plugin/StormTemplatePluginTest.kt b/storm-compiler-plugin/src/test/kotlin/st/orm/kotlin/plugin/StormTemplatePluginTest.kt index c58900aa6..cac007758 100644 --- a/storm-compiler-plugin/src/test/kotlin/st/orm/kotlin/plugin/StormTemplatePluginTest.kt +++ b/storm-compiler-plugin/src/test/kotlin/st/orm/kotlin/plugin/StormTemplatePluginTest.kt @@ -717,6 +717,29 @@ class StormTemplatePluginTest { assertEquals("42", lines[1]) } + @Test + fun `concatenated non-string constant is auto-wrapped`() { + val source = SourceFile.kotlin( + "Test.kt", + """ + import st.orm.template.* + + fun main() { + val builder: TemplateBuilder = { "SELECT * FROM users WHERE id = " + 42 + " ORDER BY name" } + val result = builder.build() + println(result.fragments.joinToString("|")) + println(result.values.joinToString(",")) + } + """, + ) + val result = compile(source) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val output = result.runMain() + val lines = output.lines() + assertEquals("SELECT * FROM users WHERE id = | ORDER BY name", lines[0]) + assertEquals("42", lines[1]) + } + @Test fun `concatenated literals stay a single fragment`() { val source = SourceFile.kotlin(