NOTE: This repo is the Terpal Compiler Plugin. The Terpal-SQL project that uses this plugin is here.
Terpal is a Kotlin compiler-plugin that allows capturing the "the $dollar $sign $varaibles" of a string before they are spliced back into the string.
Kotlin's text-interpolation currently cannot be customized at all. It is impossible to capture the value of "the $dollar $sign $varaibles" before they are spliced into the surrounding string. This makes Kotlin miss out on some very powerful tools.
For example, in Scala, libraries like Doobie and Quill use the string-prefix "sql" to specify SQL snippets such as:
sql"SELECT * FROM users WHERE id = $id AND name = $name"
In Kotlin the value of id
would be spliced directly into the string e.g. "SELECT * FROM users WHERE id = 1234 AND name = 'Joe'"
however this is highly problematic as it opens up the possibility of SQL injection attacks for example:
"SELECT * FROM users WHERE id = 1234; DROP TABLE users; AND name = 'Joe'"
Scala's string interpolation allows the library to know that "id" is a variable and should be escaped before splicing it into the string. It uses the following API:
implicit class SqlInterpolator(val sc: StringContext) extends AnyVal {
// Values of $dollar_sign_variables i.e. `id`, `name` are this list i.e. [1234, "Joe"]
def sql(params: Any*): PreparedStatement = {
// The string-parts ["SELECT * FROM users WHERE id = ", " AND name = " and ""] are this list
val stringParts: List[String] = sc.parts
...
}
}
This is a very powerful feature that allows libraries to create DSLs that are both safe and easy to use. Sadly Kotlin does not have it.
Terpal remedies this problem with a compiler-plugin that contains these exact semantics. Using Terpal, you would write the above as the following:
class SqlInterpolator(val connection: Connection): Interpolator<Any, PreparedStatement> {
// Parts is ["SELECT * FROM users WHERE id = ", " AND name = ", ""]
// Params is [`id`, `name`] i.e. [1234, "Joe"]
override fun interpolate(parts: () -> List<String>, params: () -> List<Any>): PreparedStatement {
...
}
}
val sql = SqlInterpolator<Any, PreparedStatement>(connection)
val (id, name) = 1234 to "Joe"
val stmt = sql("SELECT * FROM users WHERE id = $id AND name = $name")
// I.e the `sql.invoke(...)` function forwards the parts/params to sql.interpolate
The actual interpolate
function could easily be implemented as something like this:
override fun interpolate(parts: () -> List<String>, params: () -> List<Any>): PreparedStatement {
val stmt = connection.prepareStatement(parts().joinToString("?"))
for ((arg, i) <- params().zipWithIndex) {
stmt.setObject(i + 1, arg)
}
return stmt
}
(UPDATE - This plugin is now fully approved and available via plugins.gradle.org)
In order to use Terpal in your projects, you need to add the following to your build.gradle.kts
:
I am currently having an issue publishing to gradle. It should be resolved by tomorrow.
plugins {
kotlin("jvm") version "1.9.22"
id("io.exoquery.terpal-plugin") version "1.9.22-1.0.0-RC2.8"
}
dependencies {
api("io.exoquery:terpal-runtime:1.0.6")
}
Be sure to include the Gradle Plugin Repository and Maven repos in the pluginManagement/repositories
block of your settings.gradle.kts
:
pluginManagement {
...
repositories {
gradlePluginPortal()
mavenCentral()
mavenLocal()
}
}
If any of the terms spliced into the Terpal string throw an exception, it will be wrapped into an InterpolationException and rethrown. The InterpolationException will contain the original exception as well as some context as to the splice that caused the error:
val id by lazy { throw Exception("This is an exception") }
val name = "Joe"
Sql("SELECT * FROM users WHERE id = $id AND name = $name")
// Error in spliced code `id` expression #1 (of 2) at file:///...:72:38
A slightly longer message will be printed if the expression is multiple lines long.
Note that in some cases the code may not be exactly the same as the original code. In such cases the original code could not be loaded and needed to be retrieved from the kotlin Intermediate Representation. The message would then look like the following:
// Error in spliced (approximately looking) code `<this>.<get-id>` expression #1 (of 2) at file:///...:72:38
Terpal interpolators can be instantiated classes as well as static ones. For example, the above interpolator could have been written as:
object SqlInterpolator: Interpolator<Any, PreparedStatement> {
fun interpolate(parts: () -> List<String>, params: () -> List<Any>): PreparedStatement {
...
return stmt
}
}
In addition to classes/objects, you can use an annotation to assign a function to act as an interpolator: for example:
@InterpolatorFunction<SqlInterpolator>(SqlInterpolator::class)
fun sql2(sqlString: String): PreparedStatement = interpolatorBody()
// usage
sql2("SELECT * FROM users WHERE id = $id AND name = $name")
By using this combined with an string extension function, you can create a very compact DSL for interpolation:
@InterpolatorFunction<SqlInterpolator>(SqlInterpolator::class)
operator fun String.unaryPlus() = interpolatorBody()
// usage
+"SELECT * FROM users WHERE id = $id AND name = $name"