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..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 @@ -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,66 @@ 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 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)) } @@ -130,6 +191,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..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 @@ -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,66 @@ 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 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)) } @@ -130,6 +191,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..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 @@ -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,66 @@ 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 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)) } @@ -135,6 +196,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..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 @@ -619,6 +619,293 @@ 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 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( + "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'")}" }