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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/string-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,21 @@ orm.query { "SELECT ${t(User::class)} FROM ${t(User::class)} WHERE id = ${t(id)}

This produces identical behavior. The `t()` function is always available inside template lambdas. The compiler plugin simply automates the wrapping. Note that Storm cannot verify manual wrapping at runtime, so templates built without the plugin trigger the interpolation safety check described below.

### Constant Interpolations

Every interpolation yields a bind value, including compile-time constants:

```kotlin
const val DOMAIN = "%@gmail.com"

orm.query { "SELECT ${User::class} FROM ${User::class} WHERE email LIKE ${"%@gmail.com"}" }
orm.query { "SELECT ${User::class} FROM ${User::class} WHERE email LIKE $DOMAIN" }
```

Both templates bind `%@gmail.com` exactly like a runtime value would. The Kotlin compiler folds constant expressions into the template text before the plugin runs; the plugin recovers them from the source and verifies the result against the folded value, so constants keep value semantics. In the rare case that a folded constant cannot be recovered, the plugin reports a compile error naming the expression; wrapping the interpolation in an explicit `t()` call resolves it.

To contribute constant SQL text rather than a bind value, put the text in the template itself or concatenate literals with `+`.

### Interpolation Safety

When a `TemplateBuilder` lambda runs without the compiler plugin, Storm cannot verify that every string interpolation is wrapped in a `t()` or `interpolate()` call: a single unwrapped interpolation concatenates its value directly into the SQL. Explicit `t()` calls do not satisfy the check, because they say nothing about the other interpolations in the same template. The `storm.validation.interpolation_mode` system property controls how Storm handles templates it cannot verify:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package st.orm.kotlin.plugin

import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension
import org.jetbrains.kotlin.cli.common.CLIConfigurationKeys
import org.jetbrains.kotlin.cli.common.messages.MessageCollector
import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
import org.jetbrains.kotlin.config.CompilerConfiguration
Expand Down Expand Up @@ -29,6 +31,7 @@ class StormTemplatePluginRegistrar : CompilerPluginRegistrar() {
override val supportsK2: Boolean get() = true

override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
IrGenerationExtension.registerExtension(StormTemplateIrGenerationExtension())
val messageCollector = configuration.get(CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE)
IrGenerationExtension.registerExtension(StormTemplateIrGenerationExtension(messageCollector))
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package st.orm.kotlin.plugin

import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension
import org.jetbrains.kotlin.cli.common.messages.MessageCollector
import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
import org.jetbrains.kotlin.config.CommonConfigurationKeys
import org.jetbrains.kotlin.config.CompilerConfiguration

/**
Expand Down Expand Up @@ -31,6 +33,7 @@ class StormTemplatePluginRegistrar : CompilerPluginRegistrar() {
override val supportsK2: Boolean get() = true

override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
IrGenerationExtension.registerExtension(StormTemplateIrGenerationExtension())
val messageCollector = configuration.get(CommonConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE)
IrGenerationExtension.registerExtension(StormTemplateIrGenerationExtension(messageCollector))
}
}

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@ package st.orm.kotlin.plugin

import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension
import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext
import org.jetbrains.kotlin.cli.common.messages.MessageCollector
import org.jetbrains.kotlin.ir.declarations.IrModuleFragment
import org.jetbrains.kotlin.ir.visitors.transformChildrenVoid

/**
* IR generation extension that rewrites string template interpolations inside [TemplateBuilder] lambdas. Delegates to
* [StormTemplateIrTransformer] for the actual transformation.
* [StormTemplateIrTransformer] for the actual transformation. Diagnostics, e.g. for folded constants that cannot be
* split back into template text and values, are reported through [messageCollector].
*/
class StormTemplateIrGenerationExtension : IrGenerationExtension {
class StormTemplateIrGenerationExtension(
private val messageCollector: MessageCollector = MessageCollector.NONE,
) : IrGenerationExtension {

override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
moduleFragment.transformChildrenVoid(StormTemplateIrTransformer(pluginContext))
moduleFragment.transformChildrenVoid(StormTemplateIrTransformer(pluginContext, messageCollector))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.tschuchort.compiletesting.KotlinCompilation
import com.tschuchort.compiletesting.SourceFile
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.Test

Expand Down Expand Up @@ -1027,4 +1028,261 @@ class StormTemplatePluginTest {
assertEquals("SELECT COUNT(*) FROM users", lines[0])
assertEquals("0", lines[1])
}

// -- Folded constant tests --
//
// The compiler folds constant interpolations like ${"value"} into the surrounding template text before the
// plugin runs. The plugin parses the source to split such constants back into text and values, verifying the
// result against the folded value, and reports a compiler error when a constant cannot be split. These tests
// pin the split for the source shapes that used to defeat it: escape sequences, adjacent constants, fully
// constant templates, and multi-dollar interpolation.

private fun JvmCompilationResult.runMainEscaped(): List<String> {
val mainClass = classLoader.loadClass("TestKt")
val oldOut = System.out
val capture = java.io.ByteArrayOutputStream()
System.setOut(java.io.PrintStream(capture))
try {
mainClass.getMethod("main").invoke(null)
} finally {
System.setOut(oldOut)
}
return capture.toString().trim().lines()
}

/** Compiles a TemplateBuilder body and asserts the resulting fragments and values, newlines and tabs escaped. */
private fun assertTemplate(body: String, expectedFragments: String, expectedValues: String, languageVersion: String = "2.0", prelude: String = "") {
val source = SourceFile.kotlin(
"Test.kt",
"""
import st.orm.template.*

$prelude

fun main() {
val builder: TemplateBuilder = { $body }
val result = builder.build()
println(result.fragments.joinToString("|").replace("\n", "\\n").replace("\t", "\\t"))
println(result.values.joinToString(",").replace("\n", "\\n"))
}
""",
)
val result = compile(source, languageVersion = languageVersion)
if (languageVersion != "2.0") {
assumeCompilationSuccess(result)
}
assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages)
val lines = result.runMainEscaped()
assertEquals(expectedFragments, lines[0])
assertEquals(expectedValues, lines.getOrElse(1) { "" })
}

/**
* Compiles a TemplateBuilder body whose folded constant the plugin cannot split. Compiler versions that fold
* the constant must report an error rather than leave the interpolation as SQL text; versions that keep the
* interpolation as a runtime value already bind it correctly and must compile.
*/
private fun assertUnsplittableConstant(body: String, expectedFragments: String, expectedValues: String, prelude: String = "") {
val source = SourceFile.kotlin(
"Test.kt",
"""
import st.orm.template.*

$prelude

fun main() {
val builder: TemplateBuilder = { $body }
val result = builder.build()
println(result.fragments.joinToString("|"))
println(result.values.joinToString(","))
}
""",
)
val result = compile(source)
if (result.exitCode == KotlinCompilation.ExitCode.OK) {
val lines = result.runMainEscaped()
assertEquals(expectedFragments, lines[0])
assertEquals(expectedValues, lines.getOrElse(1) { "" })
} else {
assertEquals(KotlinCompilation.ExitCode.COMPILATION_ERROR, result.exitCode, result.messages)
assertTrue(
result.messages.contains("Storm compiler plugin cannot determine"),
"Expected the Storm unsplittable-constant error, got: ${result.messages}",
)
}
}

@Test
fun `escape sequence before inline constant is split`() {
assertTemplate(""" "a\nb${'$'}{"c"}d" """.trim(), """a\nb|d""", "c")
}

@Test
fun `escape sequence before inline constant at end of template is split`() {
assertTemplate(""" "a\tb${'$'}{"c"}" """.trim(), """a\tb|""", "c")
}

@Test
fun `escaped quote before inline constant is split`() {
assertTemplate(""" "a\"b${'$'}{"c"}" """.trim(), """a"b|""", "c")
}

@Test
fun `unicode escape before inline constant is split`() {
assertTemplate(""" "a\u00e9${'$'}{"c"}" """.trim(), "aé|", "c")
}

@Test
fun `escape sequence after inline constant is split`() {
assertTemplate(""" "a${'$'}{"c"}\nd" """.trim(), """a|\nd""", "c")
}

@Test
fun `escape sequence inside inline constant is split`() {
assertTemplate(""" "x${'$'}{"a\nb"}y" """.trim(), "x|y", """a\nb""")
}

@Test
fun `escaped dollar before inline constant is split`() {
assertTemplate(""" "a\${'$'}x ${'$'}{"c"} b" """.trim(), "a${'$'}x | b", "c")
}

@Test
fun `escaped interpolation marker stays text`() {
assertTemplate(""" "a\${'$'}{x}b" """.trim(), "a${'$'}{x}b", "")
}

@Test
fun `template consisting of only an inline constant is a value`() {
assertTemplate(""" "${'$'}{"c"}" """.trim(), "|", "c")
}

@Test
fun `adjacent inline constants are values`() {
assertTemplate(""" "${'$'}{"a"}${'$'}{"b"}" """.trim(), "||", "a,b")
}

@Test
fun `leading inline constant is a value`() {
assertTemplate(""" "${'$'}{"a"} b" """.trim(), "| b", "a")
}

@Test
fun `raw string with backslash before inline constant is split`() {
assertTemplate("\"\"\"a\\n${'$'}{\"c\"}b\"\"\"", """a\n|b""", "c")
}

@Test
fun `chain operand with escape and inline constant is split`() {
assertTemplate(""" "a\n" + "b${'$'}{"c"}d" """.trim(), """a\nb|d""", "c")
}

@Test
fun `inline int constant is a value`() {
assertTemplate(""" "LIMIT ${'$'}{42}" """.trim(), "LIMIT |", "42")
}

@Test
fun `inline char constant is a value`() {
assertTemplate(""" "a${'$'}{'c'}b" """.trim(), "a|b", "c")
}

@Test
fun `inline boolean constant is a value`() {
assertTemplate(""" "WHERE active = ${'$'}{true}" """.trim(), "WHERE active = |", "true")
}

@Test
fun `inline constant with whitespace inside braces is a value`() {
assertTemplate(""" "x${'$'}{ "c" }y" """.trim(), "x|y", "c")
}

@Test
fun `multi-dollar escape before inline constant is split`() {
assertTemplate(
""" ${'$'}${'$'}"a\nb${'$'}${'$'}{"c"}d" """.trim(),
"""a\nb|d""",
"c",
languageVersion = "2.2",
)
}

@Test
fun `multi-dollar literal marker with inline constant stays text`() {
assertTemplate(
""" ${'$'}${'$'}"WHERE ${'$'}{x} = ${'$'}${'$'}{"c"}" """.trim(),
"WHERE ${'$'}{x} = |",
"c",
languageVersion = "2.2",
)
}

@Test
fun `multi-dollar surplus dollar before inline constant stays text`() {
assertTemplate(
""" ${'$'}${'$'}"a${'$'}${'$'}${'$'}{"c"}b" """.trim(),
"a${'$'}|b",
"c",
languageVersion = "2.2",
)
}

@Test
fun `fully constant raw string with trimIndent is split`() {
assertTemplate(
"\"\"\"SELECT ${'$'}{\"c\"} FROM users\"\"\".trimIndent()",
"SELECT | FROM users",
"c",
)
}

@Test
fun `fully constant conditional branches are split`() {
assertTemplate(
""" if (System.currentTimeMillis() > 0) "a${'$'}{"c"}b" else "x${'$'}{"y"}z" """.trim(),
"a|b",
"c",
)
}

@Test
fun `folded constant reference is a value`() {
assertTemplate(
"""
val id = 42
"a ${'$'}id ${'$'}{LIMIT} b"
""".trimIndent(),
"a | | b",
"42,10",
prelude = """const val LIMIT = "10"""",
)
}

@Test
fun `folded simple-name constant reference is a value`() {
assertTemplate(
""" "a ${'$'}LIMIT b" """.trim(),
"a | b",
"10",
prelude = """const val LIMIT = "10"""",
)
}

@Test
fun `numbers in template text stay text`() {
assertTemplate(""" "SELECT name FROM users LIMIT 5" """.trim(), "SELECT name FROM users LIMIT 5", "")
}

@Test
fun `numbers in template text next to an inline constant stay text`() {
assertTemplate(""" "SELECT ${'$'}{"name"} FROM users LIMIT 5" """.trim(), "SELECT | FROM users LIMIT 5", "name")
}

@Test
fun `inline float constant binds or is reported`() {
// Kotlin 2.0 folds numeric interpolations into the template text; a float's rendering is not derived from
// the source, so the fold must surface as a compiler error rather than SQL text. Later compilers keep the
// interpolation as a runtime value.
assertUnsplittableConstant(""" "LIMIT ${'$'}{1.5}" """.trim(), "LIMIT |", "1.5")
}
}
Loading