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 683a9e2b..6c494711 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 e34d46cb..414de9e7 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 e0002a25..fb0933d3 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/FragmentTest.scala b/modules/tests/shared/src/test/scala/FragmentTest.scala index d601c7aa..c688dc4a 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 => 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 00000000..74c97d25 --- /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\"") + } +}