From ea932450b1080a918f80890561e0958030ec8a8c Mon Sep 17 00:00:00 2001 From: Matej Cerny Date: Wed, 13 May 2026 16:43:29 +0200 Subject: [PATCH 1/2] Support for quoted identifiers --- .../scala-2/syntax/StringContextOps.scala | 11 ++++ .../scala-3/syntax/StringContextOps.scala | 18 +++++ .../core/shared/src/main/scala/Channel.scala | 10 +-- .../src/main/scala/data/Identifier.scala | 28 +++++++- .../src/main/scala/net/message/package.scala | 2 +- .../src/main/laika/reference/Identifiers.md | 50 ++++++++++++++ .../docs/src/main/laika/tutorial/Channels.md | 9 +++ .../shared/src/test/scala/ChannelTest.scala | 11 ++++ .../src/test/scala/data/IdentifierTest.scala | 65 +++++++++++++++++++ 9 files changed, 195 insertions(+), 9 deletions(-) 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 0a9069cf2..ca6724a8b 100644 --- a/modules/core/shared/src/main/scala-2/syntax/StringContextOps.scala +++ b/modules/core/shared/src/main/scala-2/syntax/StringContextOps.scala @@ -19,6 +19,9 @@ class StringContextOps private[skunk](sc: StringContext) { def id(): Identifier = macro StringContextOps.StringOpsMacros.identifier_impl + def qid(): Identifier = + macro StringContextOps.StringOpsMacros.quotedIdentifier_impl + /** Construct a constant `Fragment` with no interpolated values. */ def const()(implicit or: Origin): Fragment[Void] = Fragment(sc.parts.toList.map(Left(_)), Void.codec, or) @@ -156,6 +159,14 @@ object StringContextOps { } } + def quotedIdentifier_impl(): Tree = { + val Apply(_, List(Apply(_, List(Literal(Constant(part: String)))))) = c.prefix.tree: @unchecked + Identifier.fromStringQuoted(part) match { + case Left(s) => c.abort(c.enclosingPosition, s) + case Right(Identifier(s)) => q"_root_.skunk.data.Identifier.fromStringQuoted($s).fold(sys.error, identity)" + } + } + } } 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 b711e3ed1..7f76f7fe4 100644 --- a/modules/core/shared/src/main/scala-3/syntax/StringContextOps.scala +++ b/modules/core/shared/src/main/scala-3/syntax/StringContextOps.scala @@ -154,6 +154,21 @@ object StringContextOps { return '{???} } + def qidImpl(sc: Expr[StringContext])(using qc: Quotes): Expr[Identifier] = + import qc.reflect.report + sc match { + case '{ StringContext(${Varargs(Exprs(Seq(part)))}: _*) } => + Identifier.fromStringQuoted(part) match { + case Right(Identifier(s)) => '{ Identifier.fromStringQuoted(${Expr(s)}).fold(sys.error, identity) } + case Left(s) => + report.error(s) + return '{???} + } + case _ => + report.error(s"Identifiers cannot have interpolated arguments") + return '{???} + } + } trait ToStringContextOps { @@ -164,6 +179,9 @@ trait ToStringContextOps { extension (inline sc: StringContext) inline def id(): Identifier = ${ StringContextOps.idImpl('sc) } + extension (inline sc: StringContext) inline def qid(): Identifier = + ${ StringContextOps.qidImpl('sc) } + implicit def toStringOps(sc: StringContext): StringContextOps = new StringContextOps(sc) } diff --git a/modules/core/shared/src/main/scala/Channel.scala b/modules/core/shared/src/main/scala/Channel.scala index bc7f90ef3..b3613ed0d 100644 --- a/modules/core/shared/src/main/scala/Channel.scala +++ b/modules/core/shared/src/main/scala/Channel.scala @@ -120,16 +120,16 @@ object Channel { new Channel[F, String, String] { val listen: F[Unit] = - proto.execute(Command(s"LISTEN ${name.value}", Origin.unknown, Void.codec)).void + proto.execute(Command(s"LISTEN ${name.asSql}", Origin.unknown, Void.codec)).void val unlisten: F[Unit] = - proto.execute(Command(s"UNLISTEN ${name.value}", Origin.unknown, Void.codec)).void + proto.execute(Command(s"UNLISTEN ${name.asSql}", Origin.unknown, Void.codec)).void def listen(maxQueued: Int): Stream[F, Notification[String]] = for { _ <- Stream.resource(Resource.make(listen)(_ => unlisten)) s <- Stream.resource(proto.notifications(maxQueued)) - n <- s.filter(_.channel === name) + n <- s.filter(_.channel.value === name.value) } yield n @@ -137,12 +137,12 @@ object Channel { for { _ <- Resource.make(listen)(_ => unlisten) stream <- proto.notifications(maxQueued) - } yield stream.filter(_.channel === name) + } yield stream.filter(_.channel.value === name.value) def notify(message: String): F[Unit] = // TODO: escape the message - proto.execute(Command(s"NOTIFY ${name.value}, '$message'", Origin.unknown, Void.codec)).void + proto.execute(Command(s"NOTIFY ${name.asSql}, '$message'", Origin.unknown, Void.codec)).void } diff --git a/modules/core/shared/src/main/scala/data/Identifier.scala b/modules/core/shared/src/main/scala/data/Identifier.scala index a557a21ac..373ef8afb 100644 --- a/modules/core/shared/src/main/scala/data/Identifier.scala +++ b/modules/core/shared/src/main/scala/data/Identifier.scala @@ -9,7 +9,15 @@ import cats.Eq import scala.util.matching.Regex sealed abstract case class Identifier(value: String) { - override def toString: String = value // ok? + def quoted: Boolean = false + def asSql: String = if (quoted) s"\"${value.replace("\"", "\"\"")}\"" else value + override def toString: String = asSql + + override def equals(other: Any): Boolean = other match { + case that: Identifier => this.value == that.value && this.quoted == that.quoted + case _ => false + } + override def hashCode: Int = (value, quoted).## } object Identifier { @@ -20,7 +28,7 @@ object Identifier { val pat: Regex = "([A-Za-z_][A-Za-z_0-9$]*)".r implicit val EqIdentifier: Eq[Identifier] = - Eq.by(_.value) + Eq.instance((a, b) => a.value == b.value && a.quoted == b.quoted) def fromString(s: String): Either[String, Identifier] = s match { @@ -34,6 +42,20 @@ object Identifier { case _ => Left(s"Malformed identifier: does not match ${pat.regex}") } + def fromStringQuoted(s: String): Either[String, Identifier] = { + if (s.isEmpty) + Left("Illegal identifier: zero-length delimited identifier.") + else if (s.contains('\u0000')) + Left("Illegal identifier: cannot contain the null byte (\\u0000).") + else { + val byteLen = s.getBytes(java.nio.charset.StandardCharsets.UTF_8).length + if (byteLen > maxLen) + Left(s"Identifier too long: $byteLen bytes (max allowed is $maxLen)") + else + Right(new Identifier(s) { override val quoted: Boolean = true }) + } + } + val keywords: Set[String] = Set( "A", "ABORT", "ABS", "ABSENT", "ABSOLUTE", @@ -190,4 +212,4 @@ object Identifier { "YES", "ZONE" ) -} \ No newline at end of file +} diff --git a/modules/core/shared/src/main/scala/net/message/package.scala b/modules/core/shared/src/main/scala/net/message/package.scala index 0fa11d75b..94aa364b3 100644 --- a/modules/core/shared/src/main/scala/net/message/package.scala +++ b/modules/core/shared/src/main/scala/net/message/package.scala @@ -58,7 +58,7 @@ package object message { module => val identifier: SCodec[Identifier] = utf8z.exmap( - s => Attempt.fromEither(Identifier.fromString(s).leftMap(Err(_))), + s => Attempt.fromEither(Identifier.fromString(s).orElse(Identifier.fromStringQuoted(s)).leftMap(Err(_))), id => Attempt.successful(id.value) ) diff --git a/modules/docs/src/main/laika/reference/Identifiers.md b/modules/docs/src/main/laika/reference/Identifiers.md index 329d8e448..e5ee8662f 100644 --- a/modules/docs/src/main/laika/reference/Identifiers.md +++ b/modules/docs/src/main/laika/reference/Identifiers.md @@ -1,2 +1,52 @@ +```scala mdoc:invisible +import skunk.data.Identifier +import skunk.implicits._ +``` # Identifiers +`skunk.data.Identifier` represents a Postgres SQL identifier — the name of a table, column, schema, channel, etc. Skunk validates identifiers up front so they can be safely spliced into SQL without risking injection. + +Postgres recognises two flavours of identifier, and `Identifier` supports both. + +## Unquoted identifiers + +An *unquoted* identifier matches `[A-Za-z_][A-Za-z_0-9$]*`, is at most 63 characters, and is not a reserved keyword. Postgres folds unquoted identifiers to lower case, so `FOO`, `Foo`, and `foo` all refer to the same object. + +Construct one with `Identifier.fromString` or the `id"…"` interpolator: + +```scala mdoc:compile-only +val a: Either[String, Identifier] = Identifier.fromString("my_table") +val b: Identifier = id"my_table" +``` + +The `id"…"` form validates at compile time and fails the build for malformed input. + +## Quoted (delimited) identifiers + +A *quoted* (delimited) identifier is any non-empty character sequence that does not contain the NUL byte. Quoting preserves case and lets you use characters or reserved words that an unquoted identifier cannot. + +Construct one with `Identifier.fromStringQuoted` or the `qid"…"` interpolator: + +```scala mdoc:compile-only +val a: Either[String, Identifier] = Identifier.fromStringQuoted("MyTable") // case preserved +val b: Identifier = qid"q_my_queue.INSERT" // keywords allowed +``` + +Like `id"…"`, the `qid"…"` form validates at compile time and fails the build for malformed input (empty string, embedded space, or > 63 bytes). + +Length is checked in **bytes** (Postgres' `NAMEDATALEN-1` is byte-counted), so multibyte characters are accounted for correctly. + +## Rendering as SQL + +`Identifier#asSql` returns the SQL-ready form: the bare value for unquoted identifiers, or the value wrapped in double quotes (with any embedded `"` doubled) for quoted ones. `toString` returns `asSql`, so logged identifiers show their SQL-correct form. `value` always returns the bare, unescaped name. + +```scala mdoc:compile-only +val unq = id"my_table" +val unqRendered = unq.asSql // "my_table" + +val q = qid"My.Channel" +val qBare = q.value // "My.Channel" +val qRendered = q.asSql // "\"My.Channel\"" +``` + +`Channel` uses `asSql` internally when issuing `LISTEN`/`UNLISTEN`/`NOTIFY`, so quoted channel names round-trip correctly. diff --git a/modules/docs/src/main/laika/tutorial/Channels.md b/modules/docs/src/main/laika/tutorial/Channels.md index 4a1727691..d3a69de70 100644 --- a/modules/docs/src/main/laika/tutorial/Channels.md +++ b/modules/docs/src/main/laika/tutorial/Channels.md @@ -27,6 +27,15 @@ Observe the following: - `ch` is a `Channel` which consumes `String`s and emits `Notification[String]`s. A notification is a structure that includes the process ID and channel identifier as well as the payload. - `Channel` is a profunctor and thus can be contramapped to change the input type, and mapped to change the output type. +If the channel name contains characters that are not valid in an unquoted identifier, use the `qid"…"` interpolator (or `Identifier.fromStringQuoted`) to build a quoted identifier: + +```scala mdoc:compile-only +// assume s: Session[IO] +val ch = s.channel(qid"q_my_queue.INSERT") +``` + +The resulting `LISTEN`/`UNLISTEN`/`NOTIFY` statements wrap the name in double quotes so Postgres parses it correctly. + ## Listening to a Channel To listen on a channel, construct a stream via `.listen`. diff --git a/modules/tests/shared/src/test/scala/ChannelTest.scala b/modules/tests/shared/src/test/scala/ChannelTest.scala index ab3c787fb..cbaff946f 100644 --- a/modules/tests/shared/src/test/scala/ChannelTest.scala +++ b/modules/tests/shared/src/test/scala/ChannelTest.scala @@ -56,5 +56,16 @@ class ChannelTest extends SkunkTest { } } + sessionTest("channel with quoted identifier round-trips through LISTEN/NOTIFY/UNLISTEN") { s => + val data = List("foo", "bar", "baz") + val ch = s.channel(qid"q_my_queue.INSERT") + ch.listenR(42).use { r => + for { + _ <- data.traverse_(ch.notify) + d <- r.map(_.value).takeThrough(_ != data.last).compile.toList + _ <- assert(s"channel data $d $data", data == d) + } yield "ok" + } + } } diff --git a/modules/tests/shared/src/test/scala/data/IdentifierTest.scala b/modules/tests/shared/src/test/scala/data/IdentifierTest.scala index d91b407e1..590f72f01 100644 --- a/modules/tests/shared/src/test/scala/data/IdentifierTest.scala +++ b/modules/tests/shared/src/test/scala/data/IdentifierTest.scala @@ -51,5 +51,70 @@ class IdentifierTest extends ffstest.FTest { assertEqual("value", id"foo".toString, "foo") } + test("quoted - valid with dots") { + Identifier.fromStringQuoted("q_my_queue.INSERT") match { + case Left(err) => fail(err) + case Right(id) => + for { + _ <- assertEqual("value", id.value, "q_my_queue.INSERT") + _ <- assertEqual("asSql", id.asSql, "\"q_my_queue.INSERT\"") + _ <- assertEqual("quoted", id.quoted, true) + } yield () + } + } + + test("quoted - escapes embedded double quotes") { + Identifier.fromStringQuoted("with\"quote") match { + case Left(err) => fail(err) + case Right(id) => assertEqual("asSql", id.asSql, "\"with\"\"quote\"") + } + } + + test("quoted - empty rejected") { + Identifier.fromStringQuoted("") match { + case Left(err) => err.pure[IO] + case Right(value) => fail(s"expected error, got $value") + } + } + + test("quoted - null byte rejected") { + val nullByte = '\u0000' + Identifier.fromStringQuoted(s"a${nullByte}b") match { + case Left(err) => err.pure[IO] + case Right(value) => fail(s"expected error, got $value") + } + } + + test("quoted - too long in bytes") { + Identifier.fromStringQuoted("é" * 32) match { + case Left(err) => err.pure[IO] + case Right(value) => fail(s"expected error, got $value") + } + } + + test("quoted - keywords allowed") { + Identifier.fromStringQuoted("SELECT") match { + case Left(err) => fail(err) + case Right(id) => + for { + _ <- assertEqual("value", id.value, "SELECT") + _ <- assertEqual("asSql", id.asSql, "\"SELECT\"") + } yield () + } + } + + test("unquoted - asSql equals value") { + assertEqual("asSql", id"foo".asSql, "foo") + } + + test("Eq distinguishes quoted from unquoted with same value") { + val unquoted = Identifier.fromString("foo").toOption.get + val quoted = Identifier.fromStringQuoted("foo").toOption.get + for { + _ <- IO(assert(unquoted =!= quoted)) + _ <- IO(assert(unquoted != quoted)) + } yield () + } + } From 16387907f8a3fac8875f41c549b44ff81e04c23d Mon Sep 17 00:00:00 2001 From: Matej Cerny Date: Tue, 7 Jul 2026 11:11:44 +0200 Subject: [PATCH 2/2] Support for quoted identifiers --- .../scala-2/syntax/StringContextOps.scala | 23 ++-- .../scala-3/syntax/StringContextOps.scala | 20 +-- .../core/shared/src/main/scala/Channel.scala | 12 +- .../src/main/scala/data/Identifier.scala | 37 ++++-- .../src/main/scala/net/message/package.scala | 4 +- .../src/main/laika/reference/Identifiers.md | 55 ++++---- .../docs/src/main/laika/tutorial/Channels.md | 13 +- modules/example/src/main/scala/Channel.scala | 2 +- modules/example/src/main/scala/Main.scala | 5 +- .../shared/src/test/scala/ChannelTest.scala | 17 +-- .../src/test/scala/data/IdentifierTest.scala | 124 +++++++++++------- 11 files changed, 178 insertions(+), 134 deletions(-) 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 ca6724a8b..f91ae5b2e 100644 --- a/modules/core/shared/src/main/scala-2/syntax/StringContextOps.scala +++ b/modules/core/shared/src/main/scala-2/syntax/StringContextOps.scala @@ -16,11 +16,12 @@ class StringContextOps private[skunk](sc: StringContext) { def sql(argSeq: Any*): Any = macro StringContextOps.StringOpsMacros.sql_impl + @deprecated("Use ident\"…\", which preserves the identifier verbatim. For the old case-folding behavior, pass a lower-cased literal.", "1.1.0") def id(): Identifier = macro StringContextOps.StringOpsMacros.identifier_impl - def qid(): Identifier = - macro StringContextOps.StringOpsMacros.quotedIdentifier_impl + def ident(): Identifier = + macro StringContextOps.StringOpsMacros.ident_impl /** Construct a constant `Fragment` with no interpolated values. */ def const()(implicit or: Origin): Fragment[Void] = @@ -44,6 +45,12 @@ object StringContextOps { case class Par(n: State[Int, String]) extends Part // n parameters case class Emb(ps: List[Either[String, State[Int, String]]]) extends Part + // Scala 2 drops macro method references before deprecation checking, so `id` expands to this + // deprecated helper to produce a standard warning at the call site. + @deprecated("Use ident\"…\", which preserves the identifier verbatim. For the old case-folding behavior, pass a lower-cased literal.", "1.1.0") + def legacyIdentifier(s: String): Identifier = + Identifier.fromStringLegacy(s).fold(sys.error, identity) + def fragmentFromParts[A](ps: List[Part], enc: Encoder[A], or: Origin): Fragment[A] = Fragment( ps.flatMap { @@ -153,17 +160,17 @@ object StringContextOps { def identifier_impl(): Tree = { val Apply(_, List(Apply(_, List(Literal(Constant(part: String)))))) = c.prefix.tree: @unchecked - Identifier.fromString(part) match { + Identifier.fromStringLegacy(part) match { case Left(s) => c.abort(c.enclosingPosition, s) - case Right(Identifier(s)) => q"_root_.skunk.data.Identifier.fromString($s).fold(sys.error, identity)" + case Right(Identifier(s)) => q"_root_.skunk.syntax.StringContextOps.legacyIdentifier($s)" } } - def quotedIdentifier_impl(): Tree = { + def ident_impl(): Tree = { val Apply(_, List(Apply(_, List(Literal(Constant(part: String)))))) = c.prefix.tree: @unchecked - Identifier.fromStringQuoted(part) match { + Identifier.fromValue(part) match { case Left(s) => c.abort(c.enclosingPosition, s) - case Right(Identifier(s)) => q"_root_.skunk.data.Identifier.fromStringQuoted($s).fold(sys.error, identity)" + case Right(Identifier(s)) => q"_root_.skunk.data.Identifier.fromValue($s).fold(sys.error, identity)" } } @@ -176,4 +183,4 @@ trait ToStringContextOps { new StringContextOps(sc) } -object stringcontext extends ToStringContextOps \ No newline at end of file +object stringcontext extends ToStringContextOps 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 7f76f7fe4..3b777f41e 100644 --- a/modules/core/shared/src/main/scala-3/syntax/StringContextOps.scala +++ b/modules/core/shared/src/main/scala-3/syntax/StringContextOps.scala @@ -143,8 +143,8 @@ object StringContextOps { import qc.reflect.report sc match { case '{ StringContext(${Varargs(Exprs(Seq(part)))}: _*) } => - Identifier.fromString(part) match { - case Right(Identifier(s)) => '{ Identifier.fromString(${Expr(s)}).fold(sys.error, identity) } + Identifier.fromStringLegacy(part) match { + case Right(Identifier(s)) => '{ Identifier.fromValue(${Expr(s)}).fold(sys.error, identity) } case Left(s) => report.error(s) return '{???} @@ -154,12 +154,12 @@ object StringContextOps { return '{???} } - def qidImpl(sc: Expr[StringContext])(using qc: Quotes): Expr[Identifier] = + def identImpl(sc: Expr[StringContext])(using qc: Quotes): Expr[Identifier] = import qc.reflect.report sc match { case '{ StringContext(${Varargs(Exprs(Seq(part)))}: _*) } => - Identifier.fromStringQuoted(part) match { - case Right(Identifier(s)) => '{ Identifier.fromStringQuoted(${Expr(s)}).fold(sys.error, identity) } + Identifier.fromValue(part) match { + case Right(Identifier(s)) => '{ Identifier.fromValue(${Expr(s)}).fold(sys.error, identity) } case Left(s) => report.error(s) return '{???} @@ -176,11 +176,13 @@ trait ToStringContextOps { extension (inline sc: StringContext) transparent inline def sql(inline args: Any*): Any = ${ StringContextOps.sqlImpl('sc, 'args) } - extension (inline sc: StringContext) inline def id(): Identifier = - ${ StringContextOps.idImpl('sc) } + extension (inline sc: StringContext) + @deprecated("Use ident\"…\", which preserves the identifier verbatim. For the old case-folding behavior, pass a lower-cased literal.", "1.1.0") + inline def id(): Identifier = + ${ StringContextOps.idImpl('sc) } - extension (inline sc: StringContext) inline def qid(): Identifier = - ${ StringContextOps.qidImpl('sc) } + extension (inline sc: StringContext) inline def ident(): Identifier = + ${ StringContextOps.identImpl('sc) } implicit def toStringOps(sc: StringContext): StringContextOps = new StringContextOps(sc) diff --git a/modules/core/shared/src/main/scala/Channel.scala b/modules/core/shared/src/main/scala/Channel.scala index b3613ed0d..2c10c07bf 100644 --- a/modules/core/shared/src/main/scala/Channel.scala +++ b/modules/core/shared/src/main/scala/Channel.scala @@ -120,16 +120,16 @@ object Channel { new Channel[F, String, String] { val listen: F[Unit] = - proto.execute(Command(s"LISTEN ${name.asSql}", Origin.unknown, Void.codec)).void + proto.execute(Command(s"LISTEN ${name.sql}", Origin.unknown, Void.codec)).void val unlisten: F[Unit] = - proto.execute(Command(s"UNLISTEN ${name.asSql}", Origin.unknown, Void.codec)).void + proto.execute(Command(s"UNLISTEN ${name.sql}", Origin.unknown, Void.codec)).void def listen(maxQueued: Int): Stream[F, Notification[String]] = for { _ <- Stream.resource(Resource.make(listen)(_ => unlisten)) s <- Stream.resource(proto.notifications(maxQueued)) - n <- s.filter(_.channel.value === name.value) + n <- s.filter(_.channel === name) } yield n @@ -137,12 +137,12 @@ object Channel { for { _ <- Resource.make(listen)(_ => unlisten) stream <- proto.notifications(maxQueued) - } yield stream.filter(_.channel.value === name.value) + } yield stream.filter(_.channel === name) def notify(message: String): F[Unit] = // TODO: escape the message - proto.execute(Command(s"NOTIFY ${name.asSql}, '$message'", Origin.unknown, Void.codec)).void + proto.execute(Command(s"NOTIFY ${name.sql}, '$message'", Origin.unknown, Void.codec)).void } @@ -176,4 +176,4 @@ object Channel { fab.dimap(f)(g) } -} \ No newline at end of file +} diff --git a/modules/core/shared/src/main/scala/data/Identifier.scala b/modules/core/shared/src/main/scala/data/Identifier.scala index 373ef8afb..444e8b32e 100644 --- a/modules/core/shared/src/main/scala/data/Identifier.scala +++ b/modules/core/shared/src/main/scala/data/Identifier.scala @@ -9,15 +9,9 @@ import cats.Eq import scala.util.matching.Regex sealed abstract case class Identifier(value: String) { - def quoted: Boolean = false - def asSql: String = if (quoted) s"\"${value.replace("\"", "\"\"")}\"" else value - override def toString: String = asSql - - override def equals(other: Any): Boolean = other match { - case that: Identifier => this.value == that.value && this.quoted == that.quoted - case _ => false - } - override def hashCode: Int = (value, quoted).## + lazy val sql: String = Identifier.render(value) + + override def toString: String = sql } object Identifier { @@ -26,11 +20,16 @@ object Identifier { val maxLen = 63 val pat: Regex = "([A-Za-z_][A-Za-z_0-9$]*)".r + private val unquotedPat: Regex = "([a-z_][a-z_0-9$]*)".r implicit val EqIdentifier: Eq[Identifier] = - Eq.instance((a, b) => a.value == b.value && a.quoted == b.quoted) + Eq.by(_.value) + @deprecated("Use fromValue, which preserves the identifier value and quotes it when necessary.", "1.1.0") def fromString(s: String): Either[String, Identifier] = + fromStringLegacy(s) + + private[skunk] def fromStringLegacy(s: String): Either[String, Identifier] = s match { case pat(s) => if (s.length > maxLen) @@ -38,11 +37,13 @@ object Identifier { else if (keywords(s.toUpperCase)) Left(s"Illegal identifier: ${s.toUpperCase} is a keyword.") else - Right(new Identifier(s) {}) + // Postgres folds unquoted identifiers to lower case, so we do the same up front. This + // keeps `value` consistent with what Postgres sees and ensures `sql` renders it unquoted. + Right(new Identifier(s.toLowerCase) {}) case _ => Left(s"Malformed identifier: does not match ${pat.regex}") } - def fromStringQuoted(s: String): Either[String, Identifier] = { + def fromValue(s: String): Either[String, Identifier] = if (s.isEmpty) Left("Illegal identifier: zero-length delimited identifier.") else if (s.contains('\u0000')) @@ -52,9 +53,17 @@ object Identifier { if (byteLen > maxLen) Left(s"Identifier too long: $byteLen bytes (max allowed is $maxLen)") else - Right(new Identifier(s) { override val quoted: Boolean = true }) + Right(new Identifier(s) {}) + } + + private[data] def render(s: String): String = + s match { + case unquotedPat(_) if !keywords(s.toUpperCase) => s + case _ => quote(s) } - } + + private def quote(s: String): String = + "\"" + s.replace("\"", "\"\"") + "\"" val keywords: Set[String] = Set( diff --git a/modules/core/shared/src/main/scala/net/message/package.scala b/modules/core/shared/src/main/scala/net/message/package.scala index 94aa364b3..5c1e93b44 100644 --- a/modules/core/shared/src/main/scala/net/message/package.scala +++ b/modules/core/shared/src/main/scala/net/message/package.scala @@ -58,7 +58,7 @@ package object message { module => val identifier: SCodec[Identifier] = utf8z.exmap( - s => Attempt.fromEither(Identifier.fromString(s).orElse(Identifier.fromStringQuoted(s)).leftMap(Err(_))), + s => Attempt.fromEither(Identifier.fromValue(s).leftMap(Err(_))), id => Attempt.successful(id.value) ) @@ -78,4 +78,4 @@ package object message { module => } -} \ No newline at end of file +} diff --git a/modules/docs/src/main/laika/reference/Identifiers.md b/modules/docs/src/main/laika/reference/Identifiers.md index e5ee8662f..5be1ad93c 100644 --- a/modules/docs/src/main/laika/reference/Identifiers.md +++ b/modules/docs/src/main/laika/reference/Identifiers.md @@ -4,49 +4,48 @@ import skunk.implicits._ ``` # Identifiers -`skunk.data.Identifier` represents a Postgres SQL identifier — the name of a table, column, schema, channel, etc. Skunk validates identifiers up front so they can be safely spliced into SQL without risking injection. +`skunk.data.Identifier` represents a Postgres name, such as the name of a table, column, schema, or channel. It keeps the name separate from its SQL representation so that Skunk can validate and escape it correctly. -Postgres recognises two flavours of identifier, and `Identifier` supports both. +## Constructing an Identifier -## Unquoted identifiers - -An *unquoted* identifier matches `[A-Za-z_][A-Za-z_0-9$]*`, is at most 63 characters, and is not a reserved keyword. Postgres folds unquoted identifiers to lower case, so `FOO`, `Foo`, and `foo` all refer to the same object. - -Construct one with `Identifier.fromString` or the `id"…"` interpolator: +Use the `ident"..."` interpolator for names known at compile time. It validates the literal, preserves it verbatim, and rejects invalid identifiers during compilation. ```scala mdoc:compile-only -val a: Either[String, Identifier] = Identifier.fromString("my_table") -val b: Identifier = id"my_table" +val table: Identifier = ident"country" +val column: Identifier = ident"Country.Code" +val keyword: Identifier = ident"SELECT" ``` -The `id"…"` form validates at compile time and fails the build for malformed input. - -## Quoted (delimited) identifiers - -A *quoted* (delimited) identifier is any non-empty character sequence that does not contain the NUL byte. Quoting preserves case and lets you use characters or reserved words that an unquoted identifier cannot. - -Construct one with `Identifier.fromStringQuoted` or the `qid"…"` interpolator: +Use `Identifier.fromValue` when the name is available only at run time. ```scala mdoc:compile-only -val a: Either[String, Identifier] = Identifier.fromStringQuoted("MyTable") // case preserved -val b: Identifier = qid"q_my_queue.INSERT" // keywords allowed +def identifier(name: String): Either[String, Identifier] = + Identifier.fromValue(name) ``` -Like `id"…"`, the `qid"…"` form validates at compile time and fails the build for malformed input (empty string, embedded space, or > 63 bytes). +An identifier must be non-empty, contain no NUL byte, and occupy at most 63 bytes when encoded as UTF-8. The byte limit matters for names containing multibyte characters. -Length is checked in **bytes** (Postgres' `NAMEDATALEN-1` is byte-counted), so multibyte characters are accounted for correctly. +## SQL Rendering -## Rendering as SQL +Postgres accepts simple lower-case names as bare SQL identifiers. Other names, including mixed-case names, reserved words, and names containing punctuation, must be enclosed in double quotes. `Identifier` handles this distinction automatically. -`Identifier#asSql` returns the SQL-ready form: the bare value for unquoted identifiers, or the value wrapped in double quotes (with any embedded `"` doubled) for quoted ones. `toString` returns `asSql`, so logged identifiers show their SQL-correct form. `value` always returns the bare, unescaped name. +The `sql` member returns the SQL-ready representation. It leaves a name bare when that is safe, otherwise it adds double quotes and escapes any double quotes contained in the name. `toString` returns the same representation, while `value` returns the original, unescaped name. ```scala mdoc:compile-only -val unq = id"my_table" -val unqRendered = unq.asSql // "my_table" +val plain = ident"my_table" +val plainValue = plain.value // "my_table" +val plainSql = plain.sql // "my_table" + +val mixedCase = ident"My.Channel" +val mixedCaseValue = mixedCase.value // "My.Channel" +val mixedCaseSql = mixedCase.sql // "\"My.Channel\"" -val q = qid"My.Channel" -val qBare = q.value // "My.Channel" -val qRendered = q.asSql // "\"My.Channel\"" +val embeddedQuote = ident"""say"hello""" +val embeddedQuoteSql = embeddedQuote.sql // "\"say\"\"hello\"" ``` -`Channel` uses `asSql` internally when issuing `LISTEN`/`UNLISTEN`/`NOTIFY`, so quoted channel names round-trip correctly. +`Channel` uses `sql` internally when issuing `LISTEN`/`UNLISTEN`/`NOTIFY`, so quoted channel names round-trip correctly. + +## Legacy Construction + +The deprecated `Identifier.fromString` method accepts only names matching `[A-Za-z_][A-Za-z_0-9$]*`, rejects keywords, and folds accepted names to lower case. The deprecated `id"..."` interpolator performs the same operation at compile time. New code should use `Identifier.fromValue` or `ident"..."`, which preserve the supplied name. To retain the legacy case-folding behavior, pass an already lower-case value. diff --git a/modules/docs/src/main/laika/tutorial/Channels.md b/modules/docs/src/main/laika/tutorial/Channels.md index d3a69de70..6c0cd611b 100644 --- a/modules/docs/src/main/laika/tutorial/Channels.md +++ b/modules/docs/src/main/laika/tutorial/Channels.md @@ -18,7 +18,7 @@ Use the `channel` method on `Session` to construct a channel. ```scala mdoc:compile-only // assume s: Session[IO] -val ch = s.channel(id"my_channel") // Channel[IO, String, String] +val ch = s.channel(ident"my_channel") // Channel[IO, String, String] ``` Observe the following: @@ -27,15 +27,6 @@ Observe the following: - `ch` is a `Channel` which consumes `String`s and emits `Notification[String]`s. A notification is a structure that includes the process ID and channel identifier as well as the payload. - `Channel` is a profunctor and thus can be contramapped to change the input type, and mapped to change the output type. -If the channel name contains characters that are not valid in an unquoted identifier, use the `qid"…"` interpolator (or `Identifier.fromStringQuoted`) to build a quoted identifier: - -```scala mdoc:compile-only -// assume s: Session[IO] -val ch = s.channel(qid"q_my_queue.INSERT") -``` - -The resulting `LISTEN`/`UNLISTEN`/`NOTIFY` statements wrap the name in double quotes so Postgres parses it correctly. - ## Listening to a Channel To listen on a channel, construct a stream via `.listen`. @@ -72,7 +63,7 @@ Every `Channel` is also an fs2 `Pipe` that consumes messages. // select all the country names and stream them to the country_names channel. s.prepare(sql"select name from country".query(varchar)).flatMap { ps => ps.stream(Void, 512) - .through(s.channel(id"country_names")) + .through(s.channel(ident"country_names")) .compile .drain } diff --git a/modules/example/src/main/scala/Channel.scala b/modules/example/src/main/scala/Channel.scala index 039bea2be..3739d56e8 100644 --- a/modules/example/src/main/scala/Channel.scala +++ b/modules/example/src/main/scala/Channel.scala @@ -23,7 +23,7 @@ object Channel extends IOApp { def run(args: List[String]): IO[ExitCode] = session.use { s => - s.channel(id"foo") + s.channel(ident"foo") .listen(42) .take(3) .evalMap(n => IO.println(s"⭐️⭐ $n")) diff --git a/modules/example/src/main/scala/Main.scala b/modules/example/src/main/scala/Main.scala index 8b61b6b34..bd822d5b1 100644 --- a/modules/example/src/main/scala/Main.scala +++ b/modules/example/src/main/scala/Main.scala @@ -73,12 +73,12 @@ object Main extends IOApp { st <- s.transactionStatus.get enc <- s.parameters.get.map(_.get("client_encoding")) _ <- IO.println(s"Logged in! Transaction status is $st and client_encoding is $enc") - f2 <- s.channel(id"foo").listen(10).through(anyLinesStdOut).compile.drain.start + f2 <- s.channel(ident"foo").listen(10).through(anyLinesStdOut).compile.drain.start rs <- s.execute(sql"select name, code, indepyear, population from country limit 20".query(country)) _ <- rs.traverse(IO.println) _ <- s.execute(sql"set seed = 0.123".command) _ <- s.execute(sql"set client_encoding = ISO8859_1".command) - _ <- s.channel(id"foo").notify("here is a message") + _ <- s.channel(ident"foo").notify("here is a message") _ <- s.execute(sql"select current_user".query(name)) _ <- s.prepare(q).flatMap(hmm(_)) _ <- s.prepare(in(3)).flatMap { _.stream(List("FRA", "USA", "GAB"), 100).through(anyLinesStdOut).compile.drain } @@ -100,4 +100,3 @@ object Main extends IOApp { } - diff --git a/modules/tests/shared/src/test/scala/ChannelTest.scala b/modules/tests/shared/src/test/scala/ChannelTest.scala index cbaff946f..a3a183a75 100644 --- a/modules/tests/shared/src/test/scala/ChannelTest.scala +++ b/modules/tests/shared/src/test/scala/ChannelTest.scala @@ -18,7 +18,7 @@ class ChannelTest extends SkunkTest { sessionTest("channel (coverage)") { s => val data = List("foo", "bar", "baz") - val ch0 = s.channel(id"channel_test") + val ch0 = s.channel(ident"channel_test") val ch1 = ch0.mapK(FunctionK.id) val ch2 = Functor[Channel[IO, String, *]].map(ch1)(identity[String]) val ch3 = Contravariant[Channel[IO, *, String]].contramap(ch2)(identity[String]) @@ -41,7 +41,7 @@ class ChannelTest extends SkunkTest { sessionTest("channel with resource (coverage)") { s => val data = List("foo", "bar", "baz") - val ch0 = s.channel(id"channel_test") + val ch0 = s.channel(ident"channel_test") val ch1 = ch0.mapK(FunctionK.id) val ch2 = Functor[Channel[IO, String, *]].map(ch1)(identity[String]) val ch3 = Contravariant[Channel[IO, *, String]].contramap(ch2)(identity[String]) @@ -58,13 +58,14 @@ class ChannelTest extends SkunkTest { sessionTest("channel with quoted identifier round-trips through LISTEN/NOTIFY/UNLISTEN") { s => val data = List("foo", "bar", "baz") - val ch = s.channel(qid"q_my_queue.INSERT") + val ch = s.channel(ident"q_my_queue.INSERT") + ch.listenR(42).use { r => - for { - _ <- data.traverse_(ch.notify) - d <- r.map(_.value).takeThrough(_ != data.last).compile.toList - _ <- assert(s"channel data $d $data", data == d) - } yield "ok" + for { + _ <- data.traverse_(ch.notify) + d <- r.map(_.value).takeThrough(_ != data.last).compile.toList + _ <- assert(s"channel data $d $data", data == d) + } yield "ok" } } diff --git a/modules/tests/shared/src/test/scala/data/IdentifierTest.scala b/modules/tests/shared/src/test/scala/data/IdentifierTest.scala index 590f72f01..ac96949b9 100644 --- a/modules/tests/shared/src/test/scala/data/IdentifierTest.scala +++ b/modules/tests/shared/src/test/scala/data/IdentifierTest.scala @@ -9,112 +9,148 @@ import skunk.data.Identifier import skunk.implicits._ import cats.syntax.all._ import cats.effect.IO +import scala.annotation.nowarn class IdentifierTest extends ffstest.FTest { - test("valid") { - Identifier.fromString("Identifier") match { + @nowarn("cat=deprecation") + private def legacyFromString(s: String): Either[String, Identifier] = + Identifier.fromString(s) + + test("fromString - valid, folded to lower case") { + legacyFromString("Identifier") match { case Left(err) => fail(err) - case Right(id) => assertEqual("value", id.value, "Identifier") + case Right(id) => assertEqual("value", id.value, "identifier") } } - test("invalid - lexical") { - Identifier.fromString("7_@*#&") match { + test("fromString - invalid lexical") { + legacyFromString("7_@*#&") match { case Left(err) => err.pure[IO] case Right(value) => fail(s"expected error, got $value") } } - test("invalid - too long") { - Identifier.fromString("x" * 100) match { + test("fromString - too long") { + legacyFromString("x" * 100) match { case Left(err) => err.pure[IO] case Right(value) => fail(s"expected error, got $value") } } - test("invalid - too short") { - Identifier.fromString("") match { + test("fromString - empty") { + legacyFromString("") match { case Left(err) => err.pure[IO] case Right(value) => fail(s"expected error, got $value") } } - test("invalid - reserved word") { - Identifier.fromString("select") match { + test("fromString - reserved word") { + legacyFromString("select") match { case Left(err) => err.pure[IO] case Right(value) => fail(s"expected error, got $value") } } - test("toString") { - assertEqual("value", id"foo".toString, "foo") - } - - test("quoted - valid with dots") { - Identifier.fromStringQuoted("q_my_queue.INSERT") match { + test("fromValue - valid, preserves case and dots") { + Identifier.fromValue("q_my_queue.INSERT") match { case Left(err) => fail(err) case Right(id) => for { _ <- assertEqual("value", id.value, "q_my_queue.INSERT") - _ <- assertEqual("asSql", id.asSql, "\"q_my_queue.INSERT\"") - _ <- assertEqual("quoted", id.quoted, true) + _ <- assertEqual("sql", id.sql, "\"q_my_queue.INSERT\"") } yield () } } - test("quoted - escapes embedded double quotes") { - Identifier.fromStringQuoted("with\"quote") match { + test("fromValue - keyword allowed") { + Identifier.fromValue("SELECT") match { case Left(err) => fail(err) - case Right(id) => assertEqual("asSql", id.asSql, "\"with\"\"quote\"") + case Right(id) => + for { + _ <- assertEqual("value", id.value, "SELECT") + _ <- assertEqual("sql", id.sql, "\"SELECT\"") + } yield () } } - test("quoted - empty rejected") { - Identifier.fromStringQuoted("") match { + test("fromValue - empty rejected") { + Identifier.fromValue("") match { case Left(err) => err.pure[IO] case Right(value) => fail(s"expected error, got $value") } } - test("quoted - null byte rejected") { - val nullByte = '\u0000' - Identifier.fromStringQuoted(s"a${nullByte}b") match { + test("fromValue - null byte rejected") { + Identifier.fromValue("foo" + '\u0000' + "bar") match { case Left(err) => err.pure[IO] case Right(value) => fail(s"expected error, got $value") } } - test("quoted - too long in bytes") { - Identifier.fromStringQuoted("é" * 32) match { + test("fromValue - too long in bytes") { + // "é" is two bytes in UTF-8, so 32 of them is 64 bytes, one over the limit. + Identifier.fromValue("é" * 32) match { case Left(err) => err.pure[IO] case Right(value) => fail(s"expected error, got $value") } } - test("quoted - keywords allowed") { - Identifier.fromStringQuoted("SELECT") match { + test("sql - unquoted when safe") { + assertEqual("sql", (id"foo_bar": @nowarn("cat=deprecation")).sql, "foo_bar") + } + + test("sql - quoted keyword") { + Identifier.fromValue("select") match { case Left(err) => fail(err) - case Right(id) => - for { - _ <- assertEqual("value", id.value, "SELECT") - _ <- assertEqual("asSql", id.asSql, "\"SELECT\"") - } yield () + case Right(id) => assertEqual("sql", id.sql, "\"select\"") + } + } + + test("sql - escapes embedded double quotes") { + Identifier.fromValue("has\"quote") match { + case Left(err) => fail(err) + case Right(id) => assertEqual("sql", id.sql, "\"has\"\"quote\"") } } - test("unquoted - asSql equals value") { - assertEqual("asSql", id"foo".asSql, "foo") + test("sql - mixed-case id is folded and renders unquoted") { + // Regression: the (deprecated) `id"…"` interpolator folds to lower case so it round-trips through + // Postgres unchanged, rather than being quoted (which would make it case-sensitive). + val folded = (id"Foo": @nowarn("cat=deprecation")) + for { + _ <- assertEqual("value", folded.value, "foo") + _ <- assertEqual("sql", folded.sql, "foo") + } yield () } - test("Eq distinguishes quoted from unquoted with same value") { - val unquoted = Identifier.fromString("foo").toOption.get - val quoted = Identifier.fromStringQuoted("foo").toOption.get + test("ident - interpolator preserves case and renders quoted") { + val quoted = ident"q_my_queue.INSERT" for { - _ <- IO(assert(unquoted =!= quoted)) - _ <- IO(assert(unquoted != quoted)) + _ <- assertEqual("value", quoted.value, "q_my_queue.INSERT") + _ <- assertEqual("sql", quoted.sql, "\"q_my_queue.INSERT\"") } yield () } -} + test("ident - interpolator leaves a safe lower-case name unquoted") { + val plain = ident"foo_bar" + for { + _ <- assertEqual("value", plain.value, "foo_bar") + _ <- assertEqual("sql", plain.sql, "foo_bar") + } yield () + } + test("toString returns the sql form") { + assertEqual("toString", ident"My.Channel".toString, "\"My.Channel\"") + } + + test("Eq compares identifier values") { + val legacy = legacyFromString("foo").toOption.get + val current = Identifier.fromValue("foo").toOption.get + for { + _ <- IO(assert(legacy === current)) + _ <- IO(assert(legacy == current)) + } yield () + } + +}