Skip to content
Open
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
26 changes: 26 additions & 0 deletions docs/docs/sql/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cond>` / `leftJoin[A] if <cond>` | inner / left join (`leftJoin` yields `Option[A]`) |
| `where if <cond>` | 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 |
Expand All @@ -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).

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

Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions modules/sql/core/src/main/scala/oxygen/sql/query/dsl/Q.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions modules/sql/it-test/src/test/scala/oxygen/sql/queries.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
//////////////////////////////////////////////////////////////////////////////////////////////////////
Expand Down
65 changes: 65 additions & 0 deletions report/OXY-165.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# OXY-165 — Add LIKE / ILIKE predicate support to oxygen-sql query DSL

## Goal
Add `LIKE` / `ILIKE` (+ `NOT LIKE` / `NOT ILIKE`) string-pattern predicates to the `@compile` query DSL,
usable in `where if ...`. Pattern binds as a single scalar `String` param → static SQL `col LIKE ?`.

## Findings (how the DSL works)
- `BinOp.scala`: `Comp` enum holds binary comparison ops (`==`, `<`, `@>`, ...). Purely generic — no exhaustive
matches anywhere on individual Comp cases. `StrictEnum.derive` indexes by `.sql` and `.scala` strings (must be unique).
- `RawQueryExpr.Binary` parse: matches `lhs.op(rhs)` (Select) and `op(lhs)(rhs)` (Ident, how extension methods desugar).
Looks up operator via `BinOp.scala(op)`.
- `QueryExpr.Binary` parse: routes `BinOp.Comp` → `BinaryComp`, `BinOp.AndOr` → `BinaryAndOr`. Generic.
- `FragmentBuilder.binary`: `BinaryComp(query-var, op, input-var)` → emits `col <op> ?` via `op.sqlPadded`, binding
the input encoded with the LHS column's RowRepr. This is exactly the LIKE/ILIKE shape → single `?`, static SQL.
- DSL surface (`Q.scala`): existing operators like `@>`, `<=>` are `extension`s returning a phantom type, `macroOnly`.
- Tests: integration-only against real Postgres (testcontainers). `@compile` queries live in `queries.scala`,
exercised in `CustomQuerySpec.scala` (see `ltree`, `isEmpty/nonEmpty`). No compile-time SQL-string assertions exist.

## Decisions / assumptions
- Add `like` / `ilike` / `notLike` / `notILike` as `extension (self: String)` methods (string-typed, not `[A]`),
returning `Boolean`, `macroOnly`. String-typed is stricter than the generic `[A]` operators and matches "on string columns".
- BinOp SQL strings: `LIKE`, `ILIKE`, `NOT LIKE`, `NOT ILIKE`; scala strings match method names. All unique.
- These slot into `Comp` (binary comparison), so parse/model/generation need NO further changes beyond the enum cases.
- `ILIKE` / `NOT ILIKE` are Postgres-specific — acceptable while PG-only (noted for future Dialect seam per OXY-163).
- Tests: add `@compile` queries + a `CustomQuerySpec` test covering `%`/`_` wildcards, LIKE vs ILIKE case-sensitivity,
negation, and composition (`&&`).

## Progress
- [x] Explore + design
- [x] BinOp cases (`like`/`ilike`/`notLike`/`notILike` in `Comp` enum)
- [x] Q.scala extensions (`extension (self: String)`)
- [x] Tests (5 `@compile` queries in queries.scala + `like / ilike` test in CustomQuerySpec)
- [x] Docs (docs/docs/sql/queries.md: vocab row + dedicated section)
- [x] fmt / compile / test
- [x] commit / push / PR

## What changed
- `modules/sql/core/.../generic/model/BinOp.scala` — 4 new `Comp` cases. Everything downstream
(RawQueryExpr / QueryExpr / FragmentBuilder) is generic over `Comp`, so NO other production code needed changes.
- `modules/sql/core/.../query/dsl/Q.scala` — `like`/`ilike`/`notLike`/`notILike` string extensions.
- `modules/sql/it-test/.../queries.scala` — `personFirstLike`, `personFirstILike`, `personFirstNotLike`,
`personFirstNotILike`, `personLikeAndMinAge`.
- `modules/sql/it-test/.../CustomQuerySpec.scala` — `like / ilike` test.
- `docs/docs/sql/queries.md`.

## Verification
- `oxygen-sql/compile` + `sql-it/Test/compile`: success (macro-expanded `@compile` queries compile → DSL & parsing work).
- `sql-it/testOnly oxygen.sql.CustomQuerySpec` against real Postgres (testcontainers, docker present): **13/13 pass**,
including new `like / ilike` covering `%`/`_` wildcards, LIKE vs ILIKE case-sensitivity, NOT LIKE/NOT ILIKE, and `&&` composition.
- `sbt fmt` applied; no unrelated files touched.

## Notes on the JGit workaround
- sbt-git throws `NoWorkTreeException` in a linked worktree. Temp file `zz-worktree-git-workaround.sbt` with
`ThisBuild / git.gitUncommittedChanges := false` was added to let sbt load, then **deleted before committing** (not committed).

## Open questions / defaults chosen
- Method surface restricted to `String` columns (stricter than the generic `[A]` distance ops) — matches "on string columns".
- Chose method names `like`/`ilike`/`notLike`/`notILike` (camelCase, mirrors Scala convention); SQL emitted as
`LIKE`/`ILIKE`/`NOT LIKE`/`NOT ILIKE`.
- ILIKE/NOT ILIKE are Postgres-specific; acceptable while PG-only. Flagged for the future Dialect seam (OXY-163).

## CONFIDENCE SCORE
**9 / 10** — Implementation mirrors existing generic binary-comparison machinery exactly; full compile + real-Postgres
integration tests pass. Minor residual: only the imported-extension call form (`import Q.*`) is parsed (same limitation
as existing `@>`/`<=>` operators), and no compile-time SQL-string assertion exists in the repo to lock the exact emitted text.
Loading