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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)"
Expand All @@ -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")

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.")})
}
}
}
Expand Down
14 changes: 12 additions & 2 deletions modules/docs/src/main/laika/reference/Fragments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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))
}
```
```
12 changes: 11 additions & 1 deletion modules/tests/shared/src/test/scala/FragmentTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class FragmentTest extends SkunkTest {
}
}

sessionTest("~") { s =>
sessionTest("*:") { s =>

@nigredo-tori nigredo-tori Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This used to check the twiddles' *: syntax instead of skunk's ~. I've changed the name and added another test for ~. This is unrelated to the main focus of this PR, but, IMO, this doesn't warrant a separate one.

val f = sql"select $int4" *: sql", $varchar"
s.prepare(f.query(int4 *: varchar)).flatMap { ps =>
for {
Expand All @@ -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 =>
Expand Down
25 changes: 25 additions & 0 deletions modules/tests/shared/src/test/scala/StringContextOpsTest.scala
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't found a separate test file for the syntax, so I've added a new one.


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\"")
}
}
Loading