Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
186 commits
Select commit Hold shift + click to select a range
3713cbf
feat: Add DISJOIN operator for splitting intervals at breakpoints
conradbzura May 15, 2026
e7b6b12
feat: Register DISJOIN in the MCP operator catalog
conradbzura May 15, 2026
305df32
test: Cover DISJOIN parsing, transpilation, and execution
conradbzura May 15, 2026
959237c
docs: Document the DISJOIN operator
conradbzura May 15, 2026
c895e75
test: Add operator usage-pattern matrix and functional engine suite
conradbzura May 18, 2026
b6cb344
fix: Reject malformed DISJOIN argument lists
conradbzura May 18, 2026
13ed590
fix: Resolve DISJOIN references with correct CTE scoping
conradbzura May 18, 2026
0044451
test: Run DISJOIN execution tests on SQLite and DuckDB
conradbzura May 18, 2026
a013d71
docs: Move DISJOIN into a dedicated Set Operations page
conradbzura May 18, 2026
a3c6168
docs: Sharpen DISJOIN recipe use-case descriptions
conradbzura May 18, 2026
f1a7667
fix: Reject table-function calls that supply no target
conradbzura May 19, 2026
9d16e01
fix: Reject a DISJOIN target using the reserved CTE prefix
conradbzura May 19, 2026
c8125fa
refactor: Rebuild giqldisjoin_sql from named CTE fragments
conradbzura May 19, 2026
9717f8c
test: Restructure DISJOIN test docstrings and cover the target guard
conradbzura May 19, 2026
2f71af4
test: Tidy the usage-pattern test scaffolding
conradbzura May 19, 2026
367aca4
docs: Refine the DISJOIN reference and recipe pages
conradbzura May 19, 2026
5a9ca69
build: Install pytest-manifest into the pixi test environment
conradbzura May 19, 2026
431efa8
Sync main with master (#103)
conradbzura May 19, 2026
f36557b
docs: Add DISJOIN examples to the operators demo notebook
conradbzura May 19, 2026
e143f37
Add DISJOIN operator for splitting intervals at reference breakpoints…
conradbzura May 19, 2026
abc58e9
perf: Skip redundant EXISTS clause in self-referencing DISJOIN
conradbzura May 20, 2026
c534458
test: Add edge-case transpilation tests for DISJOIN EXISTS-skip
conradbzura May 20, 2026
6c0470f
test: Add integration tests validating DISJOIN EXISTS-skip semantics
conradbzura May 20, 2026
822cdb0
refactor: Build DISJOIN WHERE clause from a clauses list
conradbzura May 20, 2026
4d6a7db
test: Strengthen DISJOIN EXISTS-skip assertions and rename outlier tests
conradbzura May 20, 2026
6c45234
Skip the redundant EXISTS containment clause in self-referencing DISJ…
conradbzura May 21, 2026
9a89d5d
refactor: Move _extract_bool_param into private-methods region of Bas…
conradbzura May 27, 2026
ba5a59a
style: Adopt Python Style Guide conventions in IntersectsBinnedJoinTr…
conradbzura May 27, 2026
e298609
refactor: Extract coordinate canonicalization to giql.canonical module
conradbzura May 27, 2026
2d49765
feat: Add IntersectsDuckDBIEJoinTransformer for column-to-column INTE…
conradbzura May 27, 2026
c2204d4
feat: Expose dialect kwarg on transpile() with mutual-exclusivity guard
conradbzura May 27, 2026
b420de6
test: Add DuckDB IEJoin dialect test coverage
conradbzura May 27, 2026
e25c177
docs: Document DuckDB IEJoin dialect in performance guide
conradbzura May 27, 2026
7b6da75
docs: Mention DuckDB IEJoin dialect opt-in in README
conradbzura May 27, 2026
8491707
chore: Refresh demo.ipynb kernel metadata to Python 3.13.3
conradbzura May 27, 2026
4b80959
Transpile INTERSECTS joins to per-chromosome IEJoin queries for DuckD…
conradbzura May 27, 2026
3093f47
feat: Add ResolveOperatorRefs normalization pass scaffolding
conradbzura Jun 9, 2026
548ad69
test: Cover resolver pass and slot descriptor contracts
conradbzura Jun 9, 2026
a3e9b1e
Build the ResolveOperatorRefs scaffolding for operator slot resolutio…
conradbzura Jun 10, 2026
6613649
refactor: Port DISJOIN emitter to resolver pass metadata
conradbzura Jun 10, 2026
d85c418
test: Pin DISJOIN coverage skip and CTE scope resolution semantics
conradbzura Jun 10, 2026
f458cd0
Port DISJOIN to consume ResolvedRef metadata from ResolveOperatorRefs…
conradbzura Jun 10, 2026
bb14e28
feat: Add CanonicalizeCoordinates pass with wrapper CTE synthesis
conradbzura Jun 10, 2026
30d4e25
test: Cover CanonicalizeCoordinates synthesis and no-op gating
conradbzura Jun 10, 2026
4a1ca86
refactor: Port NEAREST emitter to resolver pass metadata
conradbzura Jun 10, 2026
eb07548
test: Pin NEAREST reference resolution across slot forms
conradbzura Jun 10, 2026
607fb23
Build the CanonicalizeCoordinates pass with wrapper-CTE synthesis — C…
conradbzura Jun 10, 2026
f3156c0
Port NEAREST to ResolveOperatorRefs resolution with declarative slot …
conradbzura Jun 10, 2026
b65c179
refactor: Port DISTANCE emitter to resolver pass metadata
conradbzura Jun 10, 2026
716e2ac
test: Cover DISTANCE operand resolution and emitter equivalence
conradbzura Jun 10, 2026
915f49f
Port DISTANCE to ResolveOperatorRefs resolution — Closes #119 (#130)
conradbzura Jun 10, 2026
e79093a
refactor: Port spatial predicate emitters to resolver pass metadata
conradbzura Jun 10, 2026
0e1ebb2
test: Pin spatial predicate operand resolution
conradbzura Jun 10, 2026
e291ea4
Port INTERSECTS, CONTAINS, and WITHIN predicate generators to Resolve…
conradbzura Jun 10, 2026
82a8298
refactor: Drive DISJOIN coordinate canonicalization through pass 2
conradbzura Jun 10, 2026
d90bdac
test: Cover DISJOIN canonicalization and relocate moved assertions
conradbzura Jun 10, 2026
c99107b
docs: Note the non-canonical-encoding engine requirement
conradbzura Jun 10, 2026
0294f4e
Port DISJOIN off in-emitter canonicalization — Closes #122 (#133)
conradbzura Jun 10, 2026
d0b35c1
refactor: Move NEAREST, DISTANCE, and predicate canonicalization into…
conradbzura Jun 10, 2026
5129fba
test: Cover remaining-operator canonicalization and relocate assertions
conradbzura Jun 10, 2026
8bf5cba
Port NEAREST, DISTANCE, and predicates off in-emitter canonicalizatio…
conradbzura Jun 10, 2026
0224849
feat: Validate CTE and subquery projection contracts in pass 1
conradbzura Jun 10, 2026
39bb617
test: Cover CTE and subquery projection-contract validation
conradbzura Jun 10, 2026
c505dc9
docs: Document the CTE and subquery reference contract
conradbzura Jun 10, 2026
7e3c51f
Validate CTE and subquery projection contracts in ResolveOperatorRefs…
conradbzura Jun 11, 2026
c4ce83d
refactor: Remove the generator's legacy resolution fallback path
conradbzura Jun 11, 2026
6aa0c48
test: Route direct-generate tests through the transpile pipeline
conradbzura Jun 11, 2026
0989a84
Reduce the generator to a pure emitter (final audit) — Closes #125 (#…
conradbzura Jun 11, 2026
ccf45a2
feat: Add optional predicate to CLUSTER and MERGE
conradbzura Jun 11, 2026
2f3e78e
test: Cover CLUSTER and MERGE predicate behavior
conradbzura Jun 11, 2026
92e2e32
docs: Document the CLUSTER and MERGE predicate argument
conradbzura Jun 11, 2026
f5766c1
fix: Reference the predecessor with PREV() and quote predicate columns
conradbzura Jun 11, 2026
dc9dd78
docs: Update predecessor-reference docs to PREV()
conradbzura Jun 11, 2026
88304f0
test: Cover PREV() predecessor syntax and review-finding fixes
conradbzura Jun 11, 2026
3bda961
Add an optional predicate to CLUSTER/MERGE to merge only intervals th…
conradbzura Jun 11, 2026
e1842f0
fix: Add bedtools parity offset to DISTANCE non-overlapping gap
conradbzura Jun 12, 2026
0b5f3d8
test: Cover and pin bedtools parity offset for DISTANCE and NEAREST
conradbzura Jun 12, 2026
be8e081
docs: Document bedtools parity semantics for DISTANCE
conradbzura Jun 12, 2026
9f2ae39
docs: Correct MCP DISTANCE operator return description
conradbzura Jun 12, 2026
4f487e3
Fix off-by-one error in DISTANCE operator — Closes #134 (#149)
conradbzura Jun 12, 2026
fd9ca8d
feat: Add target model and resolve dialect param to a target
conradbzura Jun 13, 2026
451eb15
test: Cover the target model and DataFusion dialect routing
conradbzura Jun 13, 2026
241d310
refactor: Make targets value-equal and harden dialect resolution
conradbzura Jun 14, 2026
0866e62
test: Pin target value semantics and harden datafusion bin-size check
conradbzura Jun 14, 2026
586cb3e
Make dialect a first-class target selector driving engine-specific op…
conradbzura Jun 14, 2026
7f3d284
test: Add cross-target result oracle and DataFusion lane
conradbzura Jun 14, 2026
09230c8
feat: Add operator-expander registry and ExpandOperators pass
conradbzura Jun 14, 2026
e1213e8
test: Cover the expander registry and ExpandOperators pass
conradbzura Jun 14, 2026
38d18df
docs: Document registry teardown seam and IEJoin skip
conradbzura Jun 14, 2026
5ce9d36
refactor: Extract cross-target oracle internals into _oracle
conradbzura Jun 14, 2026
4282dc3
fix: Strict-xfail NEAREST oracle and clarify generic-vs-duckdb test
conradbzura Jun 14, 2026
e80c141
test: Expand operator-expander registry and pass coverage
conradbzura Jun 14, 2026
363a88d
fix: Synthesize empty record batch for DataFusion empty tables
conradbzura Jun 15, 2026
7be0745
test: Add engine-free unit tests for oracle internals
conradbzura Jun 15, 2026
c166bf8
test: Cover operator breadth, runners, and lane guards
conradbzura Jun 15, 2026
b10dec6
test: Fix determinism self-test method name typo
conradbzura Jun 15, 2026
0c6b3d7
fix: Harden ExpandOperators dispatch and registry surface
conradbzura Jun 15, 2026
3735154
docs: Note CLUSTER/MERGE GIQL_EXPAND is inert today
conradbzura Jun 15, 2026
f6bc9be
fix: Harden oracle normalize and guard empty targets
conradbzura Jun 15, 2026
b9755a3
fix: Reject custom oracle columns without explicit tables
conradbzura Jun 15, 2026
9f81c90
test: Replace inert strict-xfail with loud pytest.raises gaps
conradbzura Jun 15, 2026
5191bd0
test: Add 3-target, property, and split coercion oracle tests
conradbzura Jun 15, 2026
caee12a
test: Trim runner twins and tighten panic/parity assertions
conradbzura Jun 15, 2026
aa2e08a
test: Cut dead lane ceremony to the routing-purity guard
conradbzura Jun 15, 2026
58b9c1c
test: Pin nested-replace fix and harden expander coverage
conradbzura Jun 15, 2026
2640277
Build the operator-expander registry and ExpandOperators pass scaffol…
conradbzura Jun 16, 2026
229f80e
Add the cross-target result oracle and DataFusion integration lane — …
conradbzura Jun 16, 2026
0d32f61
feat: Add snapshot and restore seam to ExpanderRegistry
conradbzura Jun 28, 2026
c682c5a
refactor: Migrate DISTANCE onto the AST expander pass
conradbzura Jun 28, 2026
92b821d
test: Cover the DISTANCE expander migration and registry seam
conradbzura Jun 28, 2026
851f3bb
test: Address PR #156 review findings for DISTANCE
conradbzura Jun 28, 2026
6dda55a
test: Address round-2 review findings for DISTANCE
conradbzura Jun 29, 2026
307f466
Migrate DISTANCE to a registered operator expander (proof-of-concept)…
conradbzura Jun 29, 2026
5565263
refactor: Migrate INTERSECTS, CONTAINS, WITHIN, and set predicates to…
conradbzura Jun 29, 2026
933ce0b
Migrate INTERSECTS, CONTAINS, WITHIN, and set predicates to registere…
conradbzura Jun 29, 2026
6a4e33b
fix: Migrate NEAREST to a registered expander with capability-driven …
conradbzura Jun 29, 2026
a5f2bbc
Migrate NEAREST to a registered expander with capability-driven LATER…
conradbzura Jun 29, 2026
1aecfa3
refactor: Migrate DISJOIN to a registered expander and fix #153
conradbzura Jun 29, 2026
e62f413
Migrate DISJOIN to a registered expander and solve the star-REPLACE p…
conradbzura Jun 29, 2026
3950b19
refactor: Relocate CLUSTER and MERGE into the operator-expander registry
conradbzura Jun 30, 2026
84379be
Relocate CLUSTER and MERGE into the operator-expander registry — Clos…
conradbzura Jun 30, 2026
07d6cad
refactor: Make coordinate canonicalization capability-driven
conradbzura Jun 30, 2026
0efcfc4
Complete the DataFusion target and finalize capability-driven canonic…
conradbzura Jun 30, 2026
3b92c43
refactor: Reduce the generator to a stock per-target serializer
conradbzura Jul 1, 2026
178d704
Reduce the generator to a stock serializer, remove the migration flag…
conradbzura Jul 2, 2026
a474c6f
feat: Add a statement-finalizer seam and hide NEAREST fallback columns
conradbzura Jul 2, 2026
e717025
Hide NEAREST decorrelated-fallback internal columns from SELECT * on …
conradbzura Jul 4, 2026
637f0b2
refactor: Extract shared CLUSTER/MERGE toolkit into neutral module
conradbzura Jul 4, 2026
1dffe45
Extract the shared CLUSTER/MERGE expansion toolkit into a neutral mod…
conradbzura Jul 4, 2026
42cda0d
fix: Resolve genomic columns for CLUSTER/MERGE over a derived-table FROM
conradbzura Jul 4, 2026
c6ab843
test: Cover genomic-column resolution over a derived-table FROM
conradbzura Jul 4, 2026
68765d9
Resolve genomic columns for CLUSTER/MERGE over a derived-table FROM —…
conradbzura Jul 5, 2026
24403e2
fix: Namespace CLUSTER/MERGE synthesized identifiers with __giql_
conradbzura Jul 6, 2026
53f9a1a
test: Cover the __giql_ prefix on CLUSTER/MERGE synthesized names
conradbzura Jul 6, 2026
3825224
Use the reserved __giql_ prefix for CLUSTER/MERGE synthesized identif…
conradbzura Jul 6, 2026
660a2db
fix: Make the NEAREST fallback finalizer robust to CLUSTER/MERGE tran…
conradbzura Jul 6, 2026
881035c
test: Cover NEAREST fallback reserved-column hiding under composition
conradbzura Jul 6, 2026
d4109eb
Make the NEAREST fallback column-hiding finalizer robust to CLUSTER/M…
conradbzura Jul 6, 2026
6b4cb86
fix: Preserve the enclosing WITH clause when CLUSTER/MERGE transplant…
conradbzura Jul 6, 2026
f84931f
test: Cover WITH-clause preservation for CLUSTER/MERGE over a CTE FROM
conradbzura Jul 6, 2026
982421b
CLUSTER/MERGE over a CTE FROM drops the WITH clause, emitting non-exe…
conradbzura Jul 6, 2026
e1b936a
fix: Preserve outer LIMIT/OFFSET/DISTINCT/QUALIFY through CLUSTER/MER…
conradbzura Jul 6, 2026
4451af8
test: Cover outer result-clause preservation for CLUSTER/MERGE
conradbzura Jul 6, 2026
99f5299
CLUSTER/MERGE whole-query rewrites silently drop outer LIMIT / OFFSET…
conradbzura Jul 6, 2026
61a0b74
fix: Hide the synthesized __giql_is_new_cluster flag from CLUSTER sta…
conradbzura Jul 6, 2026
6a41ff7
test: Cover CLUSTER star flag-hiding and reconcile affected suites
conradbzura Jul 6, 2026
c4ec155
CLUSTER SELECT * leaks the synthesized __giql_is_new_cluster column i…
conradbzura Jul 6, 2026
dd95480
fix: Make CLUSTER over a qualified rel.* projection executable
conradbzura Jul 7, 2026
c2003fd
test: Cover CLUSTER over a qualified rel.* projection
conradbzura Jul 7, 2026
cb229cd
Make CLUSTER over a qualified rel.* projection executable — Closes #1…
conradbzura Jul 7, 2026
b7e5dc0
fix: Reject a star projected alongside MERGE instead of emitting brok…
conradbzura Jul 7, 2026
dcceb27
test: Cover MERGE's star-projection rejection
conradbzura Jul 7, 2026
02bd08b
MERGE over a star projection (SELECT *, MERGE(...) / SELECT t.*, MERG…
conradbzura Jul 7, 2026
9021b43
fix: Fix CLUSTER star + extra-item duplication and multi-star multipl…
conradbzura Jul 7, 2026
2ce61e9
test: Cover CLUSTER star-plus-extra-item de-duplication
conradbzura Jul 7, 2026
15390c1
fix: Reconcile MERGE projection and GROUP BY with its synthesized gro…
conradbzura Jul 7, 2026
b20c4f4
test: Cover MERGE projection and GROUP-BY reconciliation
conradbzura Jul 7, 2026
ec95c38
Fix CLUSTER star/extra-item duplication + multi-star multiplication a…
conradbzura Jul 7, 2026
942230c
refactor: Drop the binned INTERSECTS join for the naive overlap predi…
conradbzura Jul 7, 2026
bd9bbfa
test: Cover the naive INTERSECTS join and drop binned-join tests
conradbzura Jul 7, 2026
87a9e66
Drop the binned INTERSECTS join in favor of the naive overlap predica…
conradbzura Jul 7, 2026
e083a3b
refactor: Promote the DuckDB IEJoin INTERSECTS join to a registered o…
conradbzura Jul 7, 2026
2306d49
test: Cover the registered DuckDB IEJoin override and drop range_join…
conradbzura Jul 7, 2026
77d1792
fix: Route SEMI/ANTI WHERE residuals to an outer wrapper filter
conradbzura Jul 7, 2026
403d690
test: Cover the SEMI/ANTI WHERE-residual outer-filter fix
conradbzura Jul 7, 2026
96ecb5f
Promote the DuckDB IEJoin INTERSECTS implementation to a registered (…
conradbzura Jul 8, 2026
4dae5a2
fix: Decline unsupported DuckDB IEJoin shapes to the naive-predicate …
conradbzura Jul 8, 2026
82593f4
test: Cover the DuckDB IEJoin decline-to-naive fallbacks
conradbzura Jul 8, 2026
12b936a
Decline unsupported DuckDB IEJoin shapes to the naive-predicate plan …
conradbzura Jul 8, 2026
3a8e944
perf: Route DuckDB INTERSECTS SEMI/ANTI joins through IE_JOIN
conradbzura Jul 8, 2026
4a2363c
test: Cover the SEMI/ANTI IE_JOIN EXISTS/NOT EXISTS rewrite
conradbzura Jul 8, 2026
8ea5341
docs: Describe the SEMI/ANTI EXISTS plan in the performance guide
conradbzura Jul 8, 2026
caf576a
test: Retarget SEMI/ANTI property tests to a Python-native oracle
conradbzura Jul 8, 2026
b478522
DuckDB INTERSECTS SEMI/ANTI joins fall back to BLOCKWISE_NL_JOIN inst…
conradbzura Jul 8, 2026
fccffe6
perf: Route count_overlaps LEFT-join through IE_JOIN with zero-fill
conradbzura Jul 8, 2026
8dc88f8
test: Cover the count_overlaps IE_JOIN zero-fill fast path
conradbzura Jul 8, 2026
3324f02
docs: Document the count_overlaps fast path in the performance guide
conradbzura Jul 8, 2026
bb07b19
fix: Decline count_overlaps shapes that diverge from the naive plan
conradbzura Jul 8, 2026
6ebea69
test: Cover count_overlaps decline for group/projection and collisions
conradbzura Jul 8, 2026
b401bb1
Route count_overlaps (LEFT JOIN + GROUP BY on INTERSECTS) through IE_…
conradbzura Jul 9, 2026
c0cb528
fix: Cluster on the running max end so contained intervals do not split
conradbzura Jul 9, 2026
e658cc3
test: Cover CLUSTER/MERGE containment and update boundary window SQL
conradbzura Jul 9, 2026
2c9322d
docs: Clarify the CLUSTER adjacency-vs-predicate comment post running…
conradbzura Jul 9, 2026
c7ca4db
refactor: Extract the per-chromosome dynamic-SQL scaffolding to a sha…
conradbzura Jul 9, 2026
80b7bc5
CLUSTER and MERGE split contained intervals into separate clusters — …
conradbzura Jul 9, 2026
eee36a0
Extract the per-chromosome dynamic-SQL IEJoin machinery into a shared…
conradbzura Jul 9, 2026
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ print(sql)

The transpiled SQL can be executed with fast genome-unaware databases or in-memory analytic engines like DuckDB.

By default a column-to-column `INTERSECTS` join emits the naive overlap predicate (`a.chrom = b.chrom AND a.start < b.end AND b.start < a.end`) as a plain `ON` condition, which each engine's optimizer plans as a range join. For DuckDB you can additionally pass `dialect="duckdb"` to opt into a per-chromosome IEJoin plan for INNER, SEMI, or ANTI joins; shapes it declines fall through to the naive predicate. See [DuckDB IEJoin Dialect](https://giql.readthedocs.io/en/latest/transpilation/performance.html#duckdb-iejoin-dialect) for the supported shapes and fallback rules.

You can also use [oxbow](https://oxbow.readthedocs.io) to efficiently stream specialized genomics formats into DuckDB.

```python
Expand Down
155 changes: 151 additions & 4 deletions demo.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@
},
{
"cell_type": "code",
"execution_count": 3,
"execution_count": null,
"metadata": {},
"outputs": [
{
Expand Down Expand Up @@ -3579,6 +3579,152 @@
"result.head(15)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 8. DISJOIN operator\n",
"\n",
"**Split intervals at breakpoints** so that no resulting sub-interval ever partially overlaps a reference interval.\n",
"\n",
"Unlike MERGE and CLUSTER, which *collapse* intervals, DISJOIN *multiplies* rows: one input interval becomes one or more sub-interval rows. It is a table-valued function, so it appears in the `FROM` clause like NEAREST. The full input row passes through unchanged and the sub-interval is appended as `disjoin_chrom`, `disjoin_start`, and `disjoin_end`.\n",
"\n",
"In **self-mode**, `DISJOIN(features)` splits a set against its own breakpoints. Selecting the distinct sub-intervals yields the globally non-overlapping partition — the equivalent of Bioconductor's `disjoin()`.\n",
"\n",
"**GIQL Query**: `SELECT DISTINCT disjoin_chrom, disjoin_start, disjoin_end FROM DISJOIN(features_a)`"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"giql_query = \"\"\"\n",
" SELECT DISTINCT disjoin_chrom, disjoin_start, disjoin_end\n",
" FROM DISJOIN(features_a)\n",
" ORDER BY disjoin_chrom, disjoin_start\n",
"\"\"\"\n",
"\n",
"sql = transpile(giql_query, tables=[\"features_a\"])\n",
"print(\"Transpiled SQL:\")\n",
"print(sqlglot.transpile(sql, pretty=True)[0])\n",
"print(\"\\n\" + \"=\" * 80 + \"\\n\")\n",
"\n",
"n_original = conn.execute(\"SELECT COUNT(*) FROM features_a\").fetchone()[0]\n",
"result = conn.execute(sql).fetchdf()\n",
"print(\n",
" f\"Result: {len(result)} non-overlapping segments \"\n",
" f\"(from {n_original} original peaks)\"\n",
")\n",
"result.head(10)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Disjoining a set against itself\n",
"\n",
"The example above runs self-mode at scale; on a small, deliberately overlapping set every cut is visible. With no `reference`, `DISJOIN` defaults the reference to the target set, so each interval is split at every *other* interval's interior breakpoint: `A = [0, 100)` is cut where `B` starts (50); `B = [50, 150)` is cut at `A`'s end (100) and `C`'s start (120); `D = [0, 80)` is cut where `E` starts (40). Selecting `DISTINCT disjoin_*` collapses the pieces into the non-overlapping partition.\n",
"\n",
"**GIQL Query**: `SELECT * FROM DISJOIN(toy_peaks)`"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# A small set of deliberately overlapping intervals, so the split is visible.\n",
"conn.execute(\"DROP TABLE IF EXISTS toy_peaks\")\n",
"conn.execute(\n",
" 'CREATE TABLE toy_peaks(chrom VARCHAR, \"start\" INTEGER, \"end\" INTEGER, name VARCHAR)'\n",
")\n",
"conn.executemany(\n",
" \"INSERT INTO toy_peaks VALUES (?, ?, ?, ?)\",\n",
" [\n",
" (\"chr1\", 0, 100, \"A\"),\n",
" (\"chr1\", 50, 150, \"B\"),\n",
" (\"chr1\", 120, 200, \"C\"),\n",
" (\"chr2\", 0, 80, \"D\"),\n",
" (\"chr2\", 40, 80, \"E\"),\n",
" ],\n",
")\n",
"print(\"Input intervals (overlapping):\")\n",
"print(\n",
" conn.execute(\n",
" 'SELECT name, chrom, start, \"end\" FROM toy_peaks ORDER BY chrom, start'\n",
" ).fetchdf()\n",
")\n",
"print(\"\\n\" + \"=\" * 80 + \"\\n\")\n",
"\n",
"# With no reference, DISJOIN splits toy_peaks against its own breakpoints.\n",
"giql_query = \"\"\"\n",
" SELECT name, disjoin_chrom, disjoin_start, disjoin_end\n",
" FROM DISJOIN(toy_peaks)\n",
" ORDER BY disjoin_chrom, disjoin_start, name\n",
"\"\"\"\n",
"\n",
"sql = transpile(giql_query, tables=[\"toy_peaks\"])\n",
"print(\"Transpiled SQL:\")\n",
"print(sqlglot.transpile(sql, pretty=True)[0])\n",
"print(\"\\n\" + \"=\" * 80 + \"\\n\")\n",
"\n",
"result = conn.execute(sql).fetchdf()\n",
"print(\n",
" f\"Result: {len(result)} sub-intervals -- each interval split at its \"\n",
" f\"neighbours' breakpoints\"\n",
")\n",
"result"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Splitting against a reference set\n",
"\n",
"Pass a second interval set as `reference :=` to cut each target feature at that set's breakpoints, keeping only the pieces a reference interval covers. Here every peak in `features_a` is split at the breakpoints of `features_b`; the parent peak coordinates ride along with each sub-interval, and a peak that overlaps no feature in B drops out entirely.\n",
"\n",
"**GIQL Query**: `SELECT * FROM DISJOIN(features_a, reference := features_b)`"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"giql_query = \"\"\"\n",
" SELECT\n",
" chrom,\n",
" start AS peak_start,\n",
" \"end\" AS peak_end,\n",
" signal_value,\n",
" disjoin_start,\n",
" disjoin_end\n",
" FROM DISJOIN(features_a, reference := features_b)\n",
" ORDER BY chrom, disjoin_start\n",
"\"\"\"\n",
"\n",
"sql = transpile(giql_query, tables=[\"features_a\", \"features_b\"])\n",
"print(\"Transpiled SQL:\")\n",
"print(sqlglot.transpile(sql, pretty=True)[0])\n",
"print(\"\\n\" + \"=\" * 80 + \"\\n\")\n",
"\n",
"n_original = conn.execute(\"SELECT COUNT(*) FROM features_a\").fetchone()[0]\n",
"result = conn.execute(sql).fetchdf()\n",
"print(\n",
" f\"Result: {len(result)} sub-intervals of A split at B's breakpoints \"\n",
" f\"(from {n_original} original peaks)\"\n",
")\n",
"result.head(10)"
]
},
{
"cell_type": "markdown",
"metadata": {},
Expand All @@ -3587,7 +3733,7 @@
"\n",
"## Summary\n",
"\n",
"This demo showcased all 7 GIQL operators:\n",
"This demo showcased all 8 GIQL operators:\n",
"\n",
"1. **INTERSECTS**: Binary predicate for overlapping intervals\n",
"2. **WITHIN**: Binary predicate for containment (A within B)\n",
Expand All @@ -3596,6 +3742,7 @@
"5. **CLUSTER**: Aggregation operator to assign cluster IDs to overlapping intervals\n",
"6. **DISTANCE**: UDF operator to calculate genomic distances between intervals\n",
"7. **NEAREST**: Table-valued function for finding k-nearest genomic features\n",
"8. **DISJOIN**: Set operator to split intervals at reference breakpoints\n",
"\n",
"Each operator was:\n",
"- Written in GIQL syntax\n",
Expand All @@ -3608,7 +3755,7 @@
],
"metadata": {
"kernelspec": {
"display_name": "giql",
"display_name": "giql (3.13.3)",
"language": "python",
"name": "python3"
},
Expand All @@ -3622,7 +3769,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.2"
"version": "3.13.3"
}
},
"nbformat": 4,
Expand Down
134 changes: 134 additions & 0 deletions docs/dialect/aggregation-operators.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ Syntax
-- Strand-specific clustering
CLUSTER(interval, stranded := true) AS cluster_id

-- Predicate-gated clustering (run-length encoding on a column)
CLUSTER(interval, predicate := depth = PREV(depth)) AS cluster_id

-- Combined parameters
CLUSTER(interval, distance, stranded := true) AS cluster_id

Expand All @@ -56,6 +59,36 @@ Parameters
**stranded** *(optional)*
When ``true``, only cluster intervals on the same strand. Default: ``false``.

**predicate** *(optional)*
A boolean expression evaluated between each interval and its sorted
predecessor. When supplied, the cluster-boundary condition becomes
**adjacent AND predicate**: an interval stays in the current cluster only
when it is within ``distance`` of its predecessor *and* the predicate holds
between the two. A change in the predicate forces a new cluster, so an
equality predicate yields a run-length encoding of the input sequence.
Omitting the predicate preserves the default adjacency-only behavior.

Bare column references resolve to the *current* interval; the predecessor's
value of a column is referenced with ``PREV(column)``
(e.g. ``depth = PREV(depth)``). The predicate composes with ``distance`` and
``stranded`` and is evaluated under the operator's existing per-chromosome
(and per-strand) partition and start-position order.

Two constraints apply:

- **References existing columns only.** The predicate *gates* merging on
columns already present on the input rows; it does not synthesize a
statistic. Coverage depth, for example, must already be a column on the
rows (typically produced upstream by :ref:`DISJOIN <disjoin-operator>` and
aggregation).
- **Pairwise only, with single-linkage drift.** The predicate compares each
interval to its immediate sorted predecessor (everything ``LAG`` can
express). Whole-cluster conditions are out of scope. When the predicate is
not an equivalence relation (e.g. ``ABS(score - PREV(score)) < 5``),
consecutive pairs may each satisfy it while the cluster's extremes do not
— the same single-linkage behavior that ``distance``-based clustering
already exhibits.

Return Value
~~~~~~~~~~~~

Expand Down Expand Up @@ -101,6 +134,19 @@ Cluster intervals separately by strand:
FROM features
ORDER BY chrom, strand, start

**Predicate-Gated Clustering:**

Cut adjacent intervals into clusters wherever a column's value changes
(run-length encoding). ``PREV(column)`` references the predecessor row's value:

.. code-block:: sql

SELECT
*,
CLUSTER(interval, predicate := depth = PREV(depth)) AS cluster_id
FROM features
ORDER BY chrom, start

**Analyze Cluster Statistics:**

Count features per cluster:
Expand Down Expand Up @@ -145,6 +191,10 @@ Find regions with multiple overlapping features:
INNER JOIN cluster_sizes s ON c.cluster_id = s.cluster_id
WHERE s.size >= 3

.. note::

**Synthesized flag hidden under** ``SELECT *``. A ``SELECT *, CLUSTER(...)`` query materializes an internal ``__giql_is_new_cluster`` flag in a subquery, so the outer star is emitted as ``SELECT * EXCEPT (__giql_is_new_cluster)`` to keep that helper column out of the result (#184). ``* EXCEPT`` is a DataFusion-family extension: the generic and ``datafusion`` dialects emit it, while ``duckdb`` spells the exclusion ``EXCLUDE``. Transpile with ``dialect="duckdb"`` to execute on DuckDB — the portable generic ``* EXCEPT`` form is not DuckDB-runnable. A qualified ``SELECT t.*, CLUSTER(...)`` receives the same treatment: because ``CLUSTER`` runs over a single relation, the qualifier is dropped and the outer star is emitted as the same bare ``* EXCEPT (__giql_is_new_cluster)`` (#185). An explicitly-projected ``CLUSTER`` (no star) surfaces no helper column and needs no exclusion. A star may also be combined with additional explicit projection items — ``SELECT *, 1 AS extra, CLUSTER(...)`` — and each item surfaces exactly once. An *aliased* sibling (``expr AS name``) is materialized once in the inner subquery under a reserved ``__giql_sibling_N`` name, EXCEPTed from the outer star so it is not re-surfaced, and re-projected — aliased back to ``name`` — at its own position, so its output-column position is preserved even when the ``CLUSTER`` sits between the star and the item (``SELECT *, CLUSTER(...) AS cid, 1 AS extra`` keeps ``cid`` before ``extra``). Using a reserved inner name (rather than the user's alias) keeps the mechanism correct even when ``name`` collides with a base column the star surfaces (``SELECT *, score * 10 AS score`` yields both the base ``score`` and the computed one, matching the un-clustered projection) or with another sibling's alias (``SELECT *, 1 AS x, 2 AS x``). A *non-aliased* sibling (a bare column already covered by the star, or an unnamed expression) is materialized the same way, under its own reserved ``__giql_sibling_N`` name, and re-projected at its written slot — keeping its own column name (a bare column) or its rendered text (an unnamed expression) — so every sibling's output-column position matches the identical projection without ``CLUSTER`` (#190).

Performance Notes
~~~~~~~~~~~~~~~~~

Expand Down Expand Up @@ -194,6 +244,9 @@ Syntax
-- Strand-specific merge
SELECT MERGE(interval, stranded := true) FROM features

-- Predicate-gated merge (merge only equal-valued adjacent runs)
SELECT MERGE(interval, predicate := depth = PREV(depth)) FROM features

-- Merge with additional aggregations
SELECT
MERGE(interval),
Expand All @@ -214,6 +267,16 @@ Parameters
**stranded** *(optional)*
When ``true``, merge intervals separately by strand. Default: ``false``.

**predicate** *(optional)*
A boolean expression that further restricts which adjacent intervals are
merged. ``MERGE`` decomposes into :ref:`CLUSTER <cluster-operator>` plus a
``GROUP BY`` over the cluster id, so it inherits predicate-aware boundaries
directly — see the :ref:`CLUSTER predicate <cluster-operator>` description
for the full semantics, the ``PREV(column)`` convention, the
references-existing-columns-only constraint, and the pairwise-only /
single-linkage caveat. Omitting the predicate preserves the default
adjacency-only merge.

Return Value
~~~~~~~~~~~~

Expand Down Expand Up @@ -256,6 +319,24 @@ Merge intervals separately by strand:
SELECT MERGE(interval, stranded := true)
FROM features

**Predicate-Gated Merge (coverage depth):**

Merge only adjacent intervals that share the same coverage depth, reconstructing
a re-clustered, depth-segmented partition from per-breakpoint segments produced
by :ref:`DISJOIN <disjoin-operator>` and aggregation:

.. code-block:: sql

SELECT MERGE(interval, predicate := depth = PREV(depth))
FROM (
SELECT disjoin_chrom AS chrom,
disjoin_start AS start,
disjoin_end AS end,
COUNT(*) AS depth
FROM DISJOIN(features)
GROUP BY disjoin_chrom, disjoin_start, disjoin_end
) AS segments

**Merge with Feature Count:**

Count how many features were merged into each region:
Expand Down Expand Up @@ -324,6 +405,59 @@ Notes
- MERGE is an aggregate operation that processes all matching rows
- The operation sorts data internally, so pre-sorting is not required

.. note::

CLUSTER and MERGE cannot be combined in a single ``SELECT`` — MERGE
aggregates rows away while CLUSTER is a per-row window over those same rows,
so no single query expresses both. Transpiling ``SELECT MERGE(interval),
CLUSTER(interval) FROM features`` raises a ``ValueError``. Use them in
separate queries instead — for example, CLUSTER over a subquery, or MERGE
over one.

.. note::

MERGE cannot be projected alongside a star. MERGE aggregates rows into one
row per merged region, so a ``SELECT *, MERGE(...)`` or ``SELECT t.*,
MERGE(...)`` has no coherent per-row meaning — the star names the
pre-aggregation input columns of a relation MERGE has already collapsed and
grouped away. Transpiling either shape raises a ``ValueError`` rather than
emitting non-executable SQL (a bare ``*`` re-surfaces non-grouped columns
under the synthesized ``GROUP BY``; a qualified ``rel.*`` dangles an alias the
aggregation no longer exposes). Drop the star, or project only grouping
columns and aggregates (e.g. ``COUNT(*)``) alongside ``MERGE`` — not raw input
columns, which are neither grouped nor aggregated. This is unlike
:ref:`CLUSTER <cluster-operator>`, a per-row window over which a star *is*
meaningful and supported.

.. note::

**Projecting columns alongside** ``MERGE``. ``MERGE`` emits one row per merged
region and always projects the grouping keys (``chrom``, and ``strand`` when
``stranded := true``) followed by ``MIN(start)`` / ``MAX(end)``. The other items in
a ``SELECT ..., MERGE(...)`` projection are reconciled against that grouping
(#192): an explicit grouping-key column (the ``chrom`` of the *Merge by Chromosome*
shape above) is emitted once, not duplicated; an aggregate (``COUNT(*)``,
``AVG(score)``, ``STRING_AGG(name, ',')``) is computed per merged region; and an
expression over only grouping-key columns (``UPPER(chrom)``) is kept. A raw,
non-aggregated column that is neither a grouping key nor derived from one (e.g.
``score``, or ``start`` within ``MAX(score) + start``) has no coherent value per
merged region and raises a ``ValueError`` — as does a *window* aggregate over such a
column (``SUM(score) OVER (...)``), since a window is evaluated after the grouping and
does not collapse it. An item whose output name collides with the ``chrom`` /
``start`` / ``end`` columns ``MERGE`` synthesizes (e.g. ``chrom AS start``) also raises
rather than emitting two columns of that name — alias it to a distinct name instead.
The collision check folds case (``chrom AS Start`` raises too, since SQL binds the
unquoted alias onto ``start``), while an unaliased expression over a grouping key
(``CAST(chrom AS VARCHAR)``) is kept — its emitted column name is the expression, not a
bare grouping-key name. Likewise, a ``GROUP BY`` may name only the grouping-key columns
``MERGE`` groups by, as a *bare column reference*: ``GROUP BY chrom`` is honored (it is
subsumed by the synthesized per-merge grouping), while ``GROUP BY`` over any other
column — or an expression (``GROUP BY UPPER(chrom)``) or ordinal (``GROUP BY 1``) that
``MERGE`` cannot map to its grouping — raises a ``ValueError`` rather than being
silently discarded. A ``HAVING`` is honored against the per-merge grouping
(``HAVING COUNT(*) > 1`` keeps only regions built from more than one input interval)
rather than being dropped.

Related Operators
~~~~~~~~~~~~~~~~~

Expand Down
Loading
Loading