From 120728c80846fe5ffba1434c9ea57f3080c99646 Mon Sep 17 00:00:00 2001 From: Dmitry Polienko Date: Thu, 16 Jul 2026 11:06:01 +0700 Subject: [PATCH 1/2] Support `Identifier` in the `sql` interpolator Closes #1337 --- .../scala-2/syntax/StringContextOps.scala | 10 ++++++-- .../scala-3/syntax/StringContextOps.scala | 9 +++++-- .../src/main/laika/reference/Fragments.md | 14 +++++++++-- .../src/test/scala/StringContextOpsTest.scala | 25 +++++++++++++++++++ 4 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 modules/tests/shared/src/test/scala/StringContextOpsTest.scala diff --git a/modules/core/shared/src/main/scala-2/syntax/StringContextOps.scala b/modules/core/shared/src/main/scala-2/syntax/StringContextOps.scala index 683a9e2b6..6c494711e 100644 --- a/modules/core/shared/src/main/scala-2/syntax/StringContextOps.scala +++ b/modules/core/shared/src/main/scala-2/syntax/StringContextOps.scala @@ -84,11 +84,12 @@ object StringContextOps { // The interpolated args are a list of size `parts.length - 1`. We also just know this. val args = argSeq.toList - // Every arg must conform with Encoder[_] or String + // Every arg must conform with Encoder[_], Identifier, Fragment[_] or String val EncoderType = typeOf[Encoder[_]] val VoidFragmentType = typeOf[Fragment[Void]] val FragmentType = typeOf[Fragment[_]] val StringType = typeOf[String] + val IdentifierType = typeOf[Identifier] // Assemble a single list of Either[string tree, encoder int] by interleaving the stringy parts // and the args' lengths, as well as a list of the args. If the arg is an interpolated string @@ -113,6 +114,11 @@ object StringContextOps { } else c.abort(arg.pos, s"type mismatch;\n found : $argType\n required: $StringType") + } else if (argType <:< IdentifierType) { + + val p1 = q"_root_.skunk.syntax.StringContextOps.Str($str.concat($arg.sql))" + (p1 :: tail, es) + } else if (argType <:< EncoderType) { val p1 = q"_root_.skunk.syntax.StringContextOps.Str($part)" @@ -133,7 +139,7 @@ object StringContextOps { } else { - c.abort(arg.pos, s"type mismatch;\n found : $argType\n required: $EncoderType or $FragmentType") + c.abort(arg.pos, s"type mismatch;\n found : $argType\n required: $EncoderType, $IdentifierType or $FragmentType") } diff --git a/modules/core/shared/src/main/scala-3/syntax/StringContextOps.scala b/modules/core/shared/src/main/scala-3/syntax/StringContextOps.scala index e34d46cb9..414de9e7f 100644 --- a/modules/core/shared/src/main/scala-3/syntax/StringContextOps.scala +++ b/modules/core/shared/src/main/scala-3/syntax/StringContextOps.scala @@ -107,9 +107,14 @@ object StringContextOps { val newEncoders = '{ $f.encoder : Encoder[a] } :: es Right((newParts, newEncoders)) + // The interpolated thing is an Identifier + case '{ $f: Identifier } => + val newParts = '{Str(${Expr(str)})} :: '{Str($f.sql)} :: parts + Right((newParts, es)) + case '{ $a: t } => - report.error(s"Found ${Type.show[t]}, expected String, Encoder, or Fragment.", a) - Left('{compiletime.error("Expected String, Encoder, or Fragment.")}) + report.error(s"Found ${Type.show[t]}, expected Encoder, Identifier or Fragment.", a) + Left('{compiletime.error("Expected Encoder, Identifier or Fragment.")}) } } } diff --git a/modules/docs/src/main/laika/reference/Fragments.md b/modules/docs/src/main/laika/reference/Fragments.md index e0002a25f..fb0933d3b 100644 --- a/modules/docs/src/main/laika/reference/Fragments.md +++ b/modules/docs/src/main/laika/reference/Fragments.md @@ -71,7 +71,7 @@ strings, by escaping it with `#$`. @:callout(warning) Interpolating a literal string into a `Fragment` is a SQL injection risk. Never interpolate values -that have been supplied by the user. +that have been supplied by the user. See the section on interpolating identifiers for a safer alternative. @:@ Here is an example with an iterpolated literal string, as well as a normal parameter. @@ -87,6 +87,16 @@ The resulting SQL will contain `table` verbatim. frag.sql ``` +## Interpolating identifiers + +[Identifiers](Identifiers.md) can also be interpolated. An interpolated identifier is injected +directly into SQL. Quoting is used for identifiers that warrant it, making SQL injection impossible. + +```scala mdoc:silent +val tableIdent = ident"my_table" +sql"SELECT foo, bar FROM $tableIdent where foo = $int4" +``` + ## Composing Fragments Fragment products operate like encoder products, appending the resulting SQL. @@ -176,4 +186,4 @@ def usage(s: Session[IO]) = { val q = f.fragment.query(varchar) // Query[f.A, String] s.prepare(q).flatMap(_.stream(f.argument, 64).compile.to(List)) } -``` \ No newline at end of file +``` diff --git a/modules/tests/shared/src/test/scala/StringContextOpsTest.scala b/modules/tests/shared/src/test/scala/StringContextOpsTest.scala new file mode 100644 index 000000000..74c97d25b --- /dev/null +++ b/modules/tests/shared/src/test/scala/StringContextOpsTest.scala @@ -0,0 +1,25 @@ +// Copyright (c) 2018-2024 by Rob Norris and Contributors +// This software is licensed under the MIT License (MIT). +// For more information see LICENSE or https://opensource.org/licenses/MIT + +package tests +package data + +import skunk.implicits._ +import skunk.Fragment +import skunk.Void + +class StringContextOpsTest extends ffstest.FTest { + + test("allows Identifiers") { + val t = ident"my_table" + val f: Fragment[Void] = sql"SELECT * FROM $t" + assertEqual("sql", f.sql, "SELECT * FROM my_table") + } + + test("allows quoted Identifiers") { + val t = ident"my_table; drop table my_table" + val f: Fragment[Void] = sql"SELECT * FROM $t" + assertEqual("sql", f.sql, "SELECT * FROM \"my_table; drop table my_table\"") + } +} From 3d4be29893f47eb9090cdb0c1e5119048543a4e2 Mon Sep 17 00:00:00 2001 From: Dmitry Polienko Date: Thu, 16 Jul 2026 11:06:43 +0700 Subject: [PATCH 2/2] Minor cleanup in `FragmentTest` --- .../tests/shared/src/test/scala/FragmentTest.scala | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/modules/tests/shared/src/test/scala/FragmentTest.scala b/modules/tests/shared/src/test/scala/FragmentTest.scala index d601c7aaf..c688dc4a9 100644 --- a/modules/tests/shared/src/test/scala/FragmentTest.scala +++ b/modules/tests/shared/src/test/scala/FragmentTest.scala @@ -31,7 +31,7 @@ class FragmentTest extends SkunkTest { } } - sessionTest("~") { s => + sessionTest("*:") { s => val f = sql"select $int4" *: sql", $varchar" s.prepare(f.query(int4 *: varchar)).flatMap { ps => for { @@ -41,6 +41,16 @@ class FragmentTest extends SkunkTest { } } + sessionTest("~") { s => + val f = sql"select $int4" ~ sql", $varchar" + s.prepare(f.query(int4 *: varchar)).flatMap { ps => + for { + n <- ps.unique((123, "456")) + _ <- assertEqual("(123, \"456\")", n, 123 *: "456" *: EmptyTuple) + } yield "ok" + } + } + sessionTest("~>") { s => val f = sql"select" ~> sql" $int4, $varchar" s.prepare(f.query(int4 *: varchar)).flatMap { ps =>