From 1a0cee2e91c21de0cb8e0c9670d72fccb769144e Mon Sep 17 00:00:00 2001 From: Kalin Rudnicki Date: Fri, 14 Aug 2026 22:18:46 -0600 Subject: [PATCH] sql: add LIKE / ILIKE predicate support to the @compile query DSL Add `like` / `ilike` / `notLike` / `notILike` string-pattern predicates, usable in `where if ...` on String columns. The pattern binds as a single scalar String param, so the generated SQL stays static (`col LIKE ?`). These slot in as new `BinOp.Comp` cases; the rest of the pipeline (RawQueryExpr / QueryExpr / FragmentBuilder) is generic over `Comp`, so no other production code changes were needed. - BinOp: LIKE / ILIKE / NOT LIKE / NOT ILIKE cases - Q.scala: string extension methods - it-test: @compile queries + real-Postgres CustomQuerySpec test (wildcards, case-sensitivity, negation, composition) - docs/sql/queries.md ILIKE / NOT ILIKE are Postgres-specific (fine while PG-only). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011YxWKdsz97QT9BD7AdpSq6 --- docs/docs/sql/queries.md | 26 ++++++++++++ .../oxygen/sql/generic/model/BinOp.scala | 6 ++- .../main/scala/oxygen/sql/query/dsl/Q.scala | 6 +++ .../scala/oxygen/sql/CustomQuerySpec.scala | 40 ++++++++++++++++++ .../src/test/scala/oxygen/sql/queries.scala | 42 +++++++++++++++++++ 5 files changed, 119 insertions(+), 1 deletion(-) diff --git a/docs/docs/sql/queries.md b/docs/docs/sql/queries.md index bbac3f14..c7bda40f 100644 --- a/docs/docs/sql/queries.md +++ b/docs/docs/sql/queries.md @@ -121,6 +121,7 @@ input makes it a `QueryO`/`Query`. Pass `debug = true` (`@compile(debug = true)` | `select[A]` | select all columns of table `A` | | `join[A] if ` / `leftJoin[A] if ` | inner / left join (`leftJoin` yields `Option[A]`) | | `where if ` | filter | +| `s.like(p)` / `s.ilike(p)` / `s.notLike(p)` / `s.notILike(p)` | string pattern match (see below) | | `orderBy(a.field.asc, …)`, `limit(n)`, `offset(n)` | ordering / paging | | `Q.insert[A]` / `Q.update[A]` / `Q.delete[A]` | begin an insert / update / delete | | `set(_.field := value)` | assignment in an update | @@ -140,6 +141,31 @@ val personJoinNotes: QueryIO[UUID, (Person, Note)] = } yield (p, n) ``` +### String pattern matching (`LIKE` / `ILIKE`) + +On `String` columns, four predicates map to SQL's pattern-matching operators. The pattern binds as a +single scalar `String` parameter, so the generated SQL stays static (`col LIKE ?`): + +| Form | SQL | Notes | +|------|-----|-------| +| `col.like(pattern)` | `col LIKE ?` | case-sensitive | +| `col.ilike(pattern)` | `col ILIKE ?` | case-insensitive (Postgres-specific) | +| `col.notLike(pattern)` | `col NOT LIKE ?` | | +| `col.notILike(pattern)` | `col NOT ILIKE ?` | case-insensitive (Postgres-specific) | + +The pattern uses the usual SQL wildcards: `%` matches any sequence of characters and `_` matches +exactly one. Predicates compose with `&&` / `||` like any other condition. + +```scala +@compile +val searchByName: QueryIO[String, Person] = + for { + pattern <- input[String] + p <- select[Person] + _ <- where if p.first.ilike(pattern) // e.g. "al%" matches "Alice", "alfred", … + } yield p +``` + > Custom column types flow through automatically: `input[Email]` and `select[UserRow]` use the > `RowRepr`/encoder/decoder for `Email` you defined in [Models](models.md). diff --git a/modules/sql/core/src/main/scala/oxygen/sql/generic/model/BinOp.scala b/modules/sql/core/src/main/scala/oxygen/sql/generic/model/BinOp.scala index 4a63b55d..4243b65c 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/generic/model/BinOp.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/generic/model/BinOp.scala @@ -27,6 +27,10 @@ private[generic] object BinOp { case `<+>` extends Comp("<+>", "<+>") case `@>` extends Comp("@>", "@>") case `<@` extends Comp("<@", "<@") + case like extends Comp("LIKE", "like") + case ilike extends Comp("ILIKE", "ilike") + case notLike extends Comp("NOT LIKE", "notLike") + case notILike extends Comp("NOT ILIKE", "notILike") final def show: String = sql.hexFg("#E6C120").toString @@ -40,7 +44,7 @@ private[generic] object BinOp { } - // TODO (KR) : combine: `+`. `-`, `*`, `/`, `like` + // TODO (KR) : combine: `+`. `-`, `*`, `/` val sql: StrictEnum[BinOp] = StrictEnum.derive[BinOp]((op: BinOp) => op.sql) val scala: StrictEnum[BinOp] = StrictEnum.derive[BinOp]((op: BinOp) => op.scala) diff --git a/modules/sql/core/src/main/scala/oxygen/sql/query/dsl/Q.scala b/modules/sql/core/src/main/scala/oxygen/sql/query/dsl/Q.scala index e34aa6c5..8f1b551f 100644 --- a/modules/sql/core/src/main/scala/oxygen/sql/query/dsl/Q.scala +++ b/modules/sql/core/src/main/scala/oxygen/sql/query/dsl/Q.scala @@ -70,6 +70,12 @@ object Q { def @>(value: A): Boolean = macroOnly // is ancestor of (ltree) def <@(value: A): Boolean = macroOnly // is descendant of (ltree) + extension (self: String) + def like(pattern: String): Boolean = macroOnly // SQL `LIKE` (case-sensitive pattern match) + def ilike(pattern: String): Boolean = macroOnly // SQL `ILIKE` (case-insensitive pattern match, Postgres-specific) + def notLike(pattern: String): Boolean = macroOnly // SQL `NOT LIKE` + def notILike(pattern: String): Boolean = macroOnly // SQL `NOT ILIKE` (Postgres-specific) + def mkSqlString(strings: String*): String = macroOnly } diff --git a/modules/sql/it-test/src/test/scala/oxygen/sql/CustomQuerySpec.scala b/modules/sql/it-test/src/test/scala/oxygen/sql/CustomQuerySpec.scala index 97f2afa5..787c1097 100644 --- a/modules/sql/it-test/src/test/scala/oxygen/sql/CustomQuerySpec.scala +++ b/modules/sql/it-test/src/test/scala/oxygen/sql/CustomQuerySpec.scala @@ -341,6 +341,46 @@ object CustomQuerySpec extends OxygenSpec[Database] { res2 == Set(ab, abc1, abc2), ) }, + test("like / ilike") { + for { + groupId <- Random.nextUUID + + // deterministic first names covering wildcard + case-sensitivity scenarios + alice <- Person.generate(groupId)(first = "Alice", age = 30) + alicia <- Person.generate(groupId)(first = "Alicia", age = 40) + alfred <- Person.generate(groupId)(first = "Alfred", age = 50) + bob <- Person.generate(groupId)(first = "Bob", age = 60) + aliceLower <- Person.generate(groupId)(first = "alice", age = 70) + + _ <- Person.insert.all(alice, alicia, alfred, bob, aliceLower).unit + + // `%` wildcard: everything starting with "Al" (case-sensitive) -> Alice, Alicia, Alfred + likePct <- queries.personFirstLike("Al%").to[Set] + // `_` wildcard: "Ali" + exactly two chars -> Alice, Alicia is 6 -> only "Alice" (Ali + ce) + likeUnderscore <- queries.personFirstLike("Ali__").to[Set] + // LIKE is case-sensitive: "alice" pattern matches only the lowercase row + likeCase <- queries.personFirstLike("alice").to[Set] + // ILIKE is case-insensitive: "alice" matches "Alice" and "alice" + ilikeCase <- queries.personFirstILike("alice").to[Set] + // ILIKE with `%`: "al%" case-insensitively -> all the Al-names + lowercase alice + ilikePct <- queries.personFirstILike("al%").to[Set] + // NOT LIKE: not starting with "Al" (case-sensitive) -> Bob + lowercase alice + notLike <- queries.personFirstNotLike("Al%").to[Set] + // NOT ILIKE: not starting with "al" case-insensitively -> only Bob + notILike <- queries.personFirstNotILike("al%").to[Set] + // composition with `&&`: starts with "Al" AND age >= 45 -> Alfred(50) + composed <- queries.personLikeAndMinAge.execute("Al%", 45).to[Set] + } yield assertTrue( + likePct == Set(alice, alicia, alfred), + likeUnderscore == Set(alice), + likeCase == Set(aliceLower), + ilikeCase == Set(alice, aliceLower), + ilikePct == Set(alice, alicia, alfred, aliceLower), + notLike == Set(bob, aliceLower), + notILike == Set(bob), + composed == Set(alfred), + ) + }, ) override def testAspects: Chunk[CustomQuerySpec.TestSpecAspect] = Chunk(TestAspect.nondeterministic, TestAspect.withLiveClock, SqlAspects.isolateTestsInRollbackTransaction) diff --git a/modules/sql/it-test/src/test/scala/oxygen/sql/queries.scala b/modules/sql/it-test/src/test/scala/oxygen/sql/queries.scala index bdd542fb..45ef24f3 100644 --- a/modules/sql/it-test/src/test/scala/oxygen/sql/queries.scala +++ b/modules/sql/it-test/src/test/scala/oxygen/sql/queries.scala @@ -395,6 +395,48 @@ object queries { _ <- where if n.tree <@ in } yield n + @compile + val personFirstLike: QueryIO[String, Person] = + for { + pattern <- input[String] + p <- select[Person] + _ <- where if p.first.like(pattern) + } yield p + + @compile + val personFirstILike: QueryIO[String, Person] = + for { + pattern <- input[String] + p <- select[Person] + _ <- where if p.first.ilike(pattern) + } yield p + + @compile + val personFirstNotLike: QueryIO[String, Person] = + for { + pattern <- input[String] + p <- select[Person] + _ <- where if p.first.notLike(pattern) + } yield p + + @compile + val personFirstNotILike: QueryIO[String, Person] = + for { + pattern <- input[String] + p <- select[Person] + _ <- where if p.first.notILike(pattern) + } yield p + + // LIKE composed with another predicate (`&&`) + @compile + val personLikeAndMinAge: QueryIO[(String, Int), Person] = + for { + pattern <- input[String] + minAge <- input[Int] + p <- select[Person] + _ <- where if p.first.like(pattern) && p.age >= minAge + } yield p + ////////////////////////////////////////////////////////////////////////////////////////////////////// // Update //////////////////////////////////////////////////////////////////////////////////////////////////////