Skip to content

OXY-6: array input + = ANY(?) - #298

Merged
Kalin-Rudnicki merged 1 commit into
mainfrom
OXY-6
Aug 15, 2026
Merged

OXY-6: array input + = ANY(?)#298
Kalin-Rudnicki merged 1 commit into
mainfrom
OXY-6

Conversation

@Kalin-Rudnicki

Copy link
Copy Markdown
Owner

OXY-6 — Add support for array input + unnest

Adds first-class support in oxygen-sql for binding a collection as a single parameter and expanding it as Postgres col = ANY(?), avoiding N-placeholder IN lists (JDBC ~32767-param limit / plan-cache blowup).

DSL

@compile
val selectByIdArray: QueryIO[ArraySeq[UUID], Person] =
  for {
    ids <- input.array[UUID]
    p   <- select[Person]
    _   <- where if ids.contains(p.id)   // -> WHERE p.id = ANY(?)
  } yield p

Q.input.array[A] binds an ArraySeq[A] as one java.sql.Array param; ids.contains(col) generates col = ANY(?). Encoding reuses the existing RowRepr.ArrayRepr / ArraySeqEncoder (no ::type[] cast — the JDBC typed array already carries the element type).

What changed

  • DSL: T.ArrayInput, Q.input.array
  • parsing/model: VariableReference.ArrayFromInput, InputPart, RawQueryExpr/QueryExpr ArrayContains, DecoderBuilder exhaustiveness
  • generation: FragmentBuilder emits <col> = ANY(?); TypeclassExpr.RowRepr.arraySeq
  • tests: 3 compile-checked queries + 1 Postgres integration test (empty / 1000+ large / composed-with-scalar) — passes against a real container
  • docs: array-input section in docs/sql/queries.md

Scope / decisions

  • Single-column element types only (per ticket "restrict to single-column A initially").
  • = ANY(?) form only; UNNEST-as-a-table-source deferred (overlaps OXY-98 lateral join).
  • Kept distinct from OXY-17 (IN with N placeholders).
  • Full rationale + assumptions + confidence score (8.5/10) in report/OXY-6.md.

🤖 Generated with Claude Code

@Kalin-Rudnicki

Copy link
Copy Markdown
Owner Author

💬 Feedback from Kalin

The primary intended use case for the array/unnest input — in my head — is using UNNEST as an input JOIN table, e.g.:

SELECT *
  FROM UNNEST(?) a
  JOIN other b ON b.id = a

This PR implemented = ANY(?) and deferred the UNNEST-as-table-source case — but that table-source case is the main goal, not a nice-to-have. Please make that case work and give it solid tests (empty array, the join actually filtering correctly, composition with other inputs/joins, real-Postgres integration).

Spinning up an agent to build the FROM UNNEST(?) a JOIN ... support and test it thoroughly.

@Kalin-Rudnicki

Copy link
Copy Markdown
Owner Author

UNNEST(?) as an input join table source (the primary use case)

Following the feedback that the main goal was using UNNEST as an input join table (SELECT * FROM UNNEST(?) a JOIN other b ON b.id = a), this is now implemented (additive — the = ANY(?) contains support is unchanged and still passing).

DSL

select.unnest(ids) turns a previously-declared input.array[A] collection into a UNNEST(?) from-item that yields one row per element, aliased so it can be JOINed / filtered / selected like any other table:

@compile
val notesByPersonIdUnnest: QueryIO[ArraySeq[UUID], Note] =
  for {
    ids <- input.array[UUID]
    id  <- select.unnest(ids)          // FROM UNNEST(?::uuid[]) id(id)
    n   <- join[Note] if n.personId == id
  } yield n

generates:

SELECT n.id, n.person_id, n.note
    FROM UNNEST(?::uuid[]) id(id)
    JOIN note n ON n.person_id = id.id

The whole collection still binds as a single java.sql.Array param via the existing RowRepr.ArrayRepr / ArraySeqEncoder.

Notes / decisions

  • Ambiguity fix: the unnested column is named = alias and referenced qualified as alias.alias (via UNNEST(?::type[]) alias(alias)), so it never collides with a same-named column of a joined table (e.g. note.id).
  • Keeps the array declaration (input.array) separate from its table-source usage (select.unnest(ids)) — this reuses all the array-encoder wiring and composes cleanly with other inputs/joins/wheres.
  • Single-column element types only (v1), same restriction as input.array.
  • Supported as the root FROM source (the owner's example); JOIN UNNEST(?) (unnest as a non-root join item) is left as future work.

Test coverage

New integration test "UNNEST(?) as an input join table" in CustomQuerySpec, run against the real Postgres testcontainer:

  • basic join filters correctly (only matching rows return)
  • the unnested column decodes + is selectable (yield (id, n))
  • empty array → zero rows
  • larger array (all real ids + 1000 random misses), still one bound param
  • composition with an extra scalar input[String] used in a WHERE

14/14 CustomQuerySpec tests pass (new UNNEST test + the pre-existing = ANY(?) test).

Comment thread docs/docs/sql/queries.md Outdated
Comment thread modules/sql/core/src/main/scala/oxygen/sql/query/dsl/Q.scala
Comment thread report/OXY-6.md Outdated
Kalin-Rudnicki added a commit that referenced this pull request Aug 15, 2026
- `input.array[A]` now binds a `Seq[A]` (was `ArraySeq[A]`); callers pass their
  collection directly, since it is adapted to a JDBC array at bind time anyway.
- add `input.set[A]` binding a `Set[A]` via the same `= ANY(?)` / `UNNEST(?)` path.
- `VariableReference.ArrayFromInput` gains an `ArrayInputKind` (Seq | Set);
  `TypeclassExpr.RowRepr.collectionAsArray(kind)` wraps `RowRepr[A]` into
  `RowRepr[Seq[A]]` / `RowRepr[Set[A]]` via `ArrayRepr` + `transform`.
- `Q.select.unnest` accepts `Iterable[A]` so both Seq and Set inputs unnest.
- FromUnnest: move the `UNNEST(` closing paren into `GeneratedFragment.of`.
- report/OXY-6.md moved to a comment on the OXY-6 Jira issue and removed.
- tests: Seq/Set `= ANY(?)` + Set UNNEST cases; docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread modules/sql/core/src/main/scala/oxygen/sql/generic/model/TypeclassExpr.scala Outdated
Add first-class collection inputs to the sql query DSL, bound as a single
`java.sql.Array` param instead of expanding to N placeholders (avoids the JDBC
~32767-param limit and plan-cache blowup).

- `input.array[A]` (a `Seq[A]`) and `input.set[A]` (a `Set[A]`) + `arr.contains(col)`
  generate `col = ANY(?)`.
- `select.unnest(arr)` uses the collection as a `FROM UNNEST(?::t[]) a(a)` table
  source (one row per element), aliased/qualified so it can be joined/filtered/selected.
- `RowRepr.seqRepr` / `RowRepr.setRepr` wrap `RowRepr[A]` into `RowRepr[Seq[A]]` /
  `RowRepr[Set[A]]` (single Array column via `ArraySeqEncoder`); the runtime collection
  is adapted to a JDBC array at bind time, so callers pass their collection directly.
- single-column element types only; array/set inputs are non-optional.
- docs + it-test coverage (empty / single / large / NOT-found / composition / Set).

Report captured on the OXY-6 Jira issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Kalin-Rudnicki
Kalin-Rudnicki merged commit 0d2bcb8 into main Aug 15, 2026
7 checks passed
@Kalin-Rudnicki
Kalin-Rudnicki deleted the OXY-6 branch August 15, 2026 04:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant