Skip to main content
Version: Next

String Templates

String templates are the mechanism that makes Storm's SQL template engine injection-safe by design. Rather than concatenating SQL strings (which invites SQL injection), Storm uses language-level string interpolation that separates SQL fragments from parameter values at compile time. This page explains how string templates work in both Kotlin and Java, their current status, and how to set them up.


Overview

Storm's SQL template engine accepts a template consisting of fragments (the literal SQL parts) and values (the interpolated expressions). The engine never concatenates values into SQL text. Instead, values are processed by the template engine: types expand into column lists, metamodel fields resolve to column names, and plain values become parameterized placeholders (?). This design makes SQL injection structurally impossible.

Both Kotlin and Java provide language-level string interpolation that Storm leverages for this purpose, but each language takes a different approach.

KotlinJava
Syntax$variable or ${expression}\{expression}
MechanismCompiler plugin (auto-wraps interpolations)String Templates (preview feature)
StatusStable (Kotlin 2.0+)Preview (JDK 21 exactly)
Modulestorm-kotlinstorm-java21

Kotlin

How It Works

Kotlin's string interpolation (${}) is a stable language feature. Storm provides a compiler plugin that transforms interpolated expressions inside template lambdas at compile time.

When you write:

orm.query { "SELECT ${User::class} FROM ${User::class} WHERE id = $id" }

The compiler plugin detects that the lambda has a TemplateContext receiver and automatically wraps each interpolated expression in a t() call:

orm.query { "SELECT ${t(User::class)} FROM ${t(User::class)} WHERE id = ${t(id)}" }

The t() function is the single entry point for all template elements. It handles types (expanding to column lists), metamodel fields (resolving to column names with aliases), and plain values (becoming parameterized placeholders). The compiler plugin inserts these calls so you don't have to.

This transformation happens at compile time and produces identical bytecode to writing t() manually. The resulting template is then processed by Storm's SQL template engine, which splits the string on the t() boundaries to obtain fragments and values.

Setup

Add the Storm compiler plugin to your Kotlin compiler configuration. The plugin is published as a separate artifact per Kotlin major.minor version, so that each artifact is compiled against the matching Kotlin compiler API. Choose the artifact that matches the Kotlin version in your project:

Kotlin versionArtifact ID
2.0.xstorm-compiler-plugin-2.0
2.1.xstorm-compiler-plugin-2.1
2.2.xstorm-compiler-plugin-2.2
2.3.xstorm-compiler-plugin-2.3
2.4.xstorm-compiler-plugin-2.4

The artifact version matches the Storm version (e.g., 1.13.1).

The Storm Gradle plugin selects and applies the matching variant automatically, so if you apply it you can skip this step. The setup below is for Maven and for Gradle users who configure it explicitly.

dependencies {
kotlinCompilerPluginClasspath("st.orm:storm-compiler-plugin-2.0")
}

The plugin activates automatically via service loader once it is on the Kotlin compiler classpath. No additional configuration flags are needed.

Without the Compiler Plugin

The compiler plugin is optional. Without it, you can still use Storm's template engine by wrapping interpolations in t() manually:

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:

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:

ValueBehavior
warnLogs a warning (default). Suitable for development.
failThrows an IllegalStateException. Recommended for production.
noneDisables the check entirely.

Any other value fails with an IllegalStateException naming the valid values.

In warn mode (the default), Storm logs the following message:

WARNING: TemplateBuilder lambda executed without the Storm compiler plugin. Storm cannot
verify that every string interpolation is wrapped in a t() or interpolate() call; an
unwrapped interpolation concatenates its value directly into the SQL, risking SQL injection.
See https://orm.st/string-templates for setup instructions.
To change this behavior, set -Dstorm.validation.interpolation_mode=warn|fail|none.

The warning is logged once per JVM: one occurrence identifies the problem, and the remedy (applying the compiler plugin) is global. fail mode applies to every template.

This helps catch cases where the compiler plugin is missing from the build configuration, causing interpolated values to be concatenated directly into the SQL string instead of being parameterized.

Configuring the mode:

# Production: fail on missing compiler plugin
java -Dstorm.validation.interpolation_mode=fail -jar myapp.jar

# Disable the check entirely
java -Dstorm.validation.interpolation_mode=none -jar myapp.jar

See Configuration for details and recommended production settings.

Template Functions

Inside a template lambda, the TemplateContext receiver provides several functions for controlling how expressions are interpreted. With the compiler plugin, these functions are passed through t() automatically:

// Type reference (expands to column list in SELECT, table with joins in FROM)
orm.query { "SELECT ${User::class} FROM ${User::class}" }

// Metamodel column reference (resolves to column name with alias)
orm.query { "SELECT ${User::class} FROM ${User::class} WHERE ${User_.email} = $email" }

// Explicit column reference
orm.query { "SELECT ${User::class} FROM ${User::class} ORDER BY ${column(User_.email)}" }

// Table reference without auto-join
orm.query { "FROM ${from(User::class, autoJoin = false)} JOIN ${table(City::class)} ON ..." }

// Raw SQL (use with caution, bypasses parameterization)
orm.query { "SELECT ${User::class} FROM ${User::class} WHERE ${unsafe("name = 'Alice'")}" }

Fallback: Manual t() Wrapping

If the compiler plugin is not available, you can wrap interpolations in t() manually. The compiler plugin detects existing t() and interpolate() calls and leaves them unchanged, so mixing both styles in the same project is safe:

orm.query { "SELECT ${t(User::class)} FROM ${t(User::class)} WHERE id = ${t(id)}" }

Manual t() calls cannot be verified at runtime, so templates built without the compiler plugin still trigger the interpolation safety check and log a warning by default. If you wrap every interpolation manually, or use pure literal templates without any interpolations, you can disable the check with the JVM system property:

-Dstorm.validation.interpolation_mode=none

Java

How It Works

Java's String Templates (preview feature since Java 21) provide a StringTemplate processor mechanism. Storm's RAW processor receives the template fragments and values directly from the language runtime, giving Storm the same structural separation as the Kotlin approach.

orm.query(RAW."""
SELECT \{User.class}
FROM \{User.class}
WHERE \{User_.email} = \{email}""")

The \{expression} syntax is Java's string template interpolation. The RAW processor passes fragments and values to Storm's template engine without any string concatenation.

Status

Java String Templates are a preview feature that is still evolving in the JDK. Storm is a forward-looking framework, and String Templates are the best way to write SQL in Java that is both readable and injection-safe by design.

Rather than wait for the feature to stabilize, Storm ships with String Template support today. The Java API is production-ready from a quality perspective, but its API surface will adapt to whatever shape the platform ultimately gives the feature. If the JDK reintroduces string templates as a stable feature, the Java API moves front and center alongside Kotlin: same first-class status, no preview flags, no version pin. Until then, the preview pin applies: preview class files are version-locked, so the Java path compiles and runs on JDK 21 exactly.

Only storm-java21 depends on this preview feature. The core framework and the Kotlin API are unaffected.

Setup

Enable preview features in your Java compiler configuration:

tasks.withType<JavaCompile> {
options.compilerArgs.add("--enable-preview")
}

Template Elements

Java uses the same template elements as Kotlin, but without the t() wrapper (the RAW processor handles the separation directly):

// Type reference
orm.query(RAW."SELECT \{User.class} FROM \{User.class}")

// Metamodel column reference
orm.query(RAW."SELECT \{User.class} FROM \{User.class} WHERE \{User_.email} = \{email}")

// Explicit column and table references
orm.query(RAW."FROM \{from(User.class, false)} JOIN \{table(City.class)} ON ...")

// Raw SQL
orm.query(RAW."SELECT \{User.class} FROM \{User.class} WHERE \{unsafe("name = 'Alice'")}")

Comparison

Both approaches achieve the same goal: structurally safe SQL templates with compile-time separation of fragments and values. The difference is in how they get there.

AspectKotlin (Compiler Plugin)Java (String Templates)
Interpolation${expression} (auto-wrapped by plugin)\{expression} (processed by RAW)
Plugin/flag requiredStorm compiler plugin--enable-preview
MultilineTriple-quoted strings ("""...""")Text blocks ("""...""")
Template functionscolumn(), table(), from(), unsafe()Same functions available
Explicit wrappingt() available but optional with pluginNot needed (RAW handles it)

Both languages support all Storm template features: type expansion, metamodel column references, auto-join generation, subqueries, and raw SQL injection via unsafe().