Skip to content

Commit 882e5c1

Browse files
committed
feat: recursive query result/walk filters, __depth, via!both, embedding
BREAKING: plain filters on recursive queries now filter the result; the prune-the-walk behavior is now opt-in via the walk. prefix.
1 parent 07c1ae5 commit 882e5c1

8 files changed

Lines changed: 419 additions & 81 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# Change Log
22

3+
## 0.7.0 - 2026-06-15
4+
5+
### Added
6+
* `__depth` selectable pseudo-column on recursive queries — reports each row's traversal depth (seed = 0); in `via` mode it reports the shallowest depth at which a node is reached
7+
* `walk.` filter prefix to prune the traversal — skips a non-matching node and everything beyond it
8+
* Bidirectional graph traversal with `via!both(src,dst)` — follows edges in either direction
9+
* Resource embedding now composes with single-table recursion (not supported together with `via()` traversal)
10+
11+
### Changed
12+
* **Breaking:** plain filters on recursive queries now filter the *result* instead of pruning the traversal; the previous prune-the-walk behavior is now opt-in via the `walk.` prefix
13+
14+
## 0.6.1 - 2026-06-12
15+
16+
### Fixed
17+
* JSONB filter behavior now matches PostgREST (#19): JSON-path filters with quoted string values, containment against JSON string arrays, and whole-column JSONB equality/inequality
18+
19+
### Improved
20+
* Improved release process (goreleaser config and `make release` target)
21+
* Updated Go and UI dependencies
22+
323
## 0.6.0 - 2026-04-17
424

525
### Added

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ test:
1818
$(GO) test $(TEST_FLAGS) ./database
1919
$(GO) test $(TEST_FLAGS) ./test/api
2020
$(GO) test $(TEST_FLAGS) ./test/postgrest
21+
$(GO) test $(TEST_FLAGS) ./test/recursive
2122

2223
# This is used to recreate PostgREST fixtures
2324
prepare-postgrest-tests:

README.md

Lines changed: 11 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -397,65 +397,37 @@ When disabled, attempts to use aggregate functions will return an error.
397397
### Recursive Queries
398398

399399
> [!NOTE]
400-
> This is a SmoothDB extension to PostgREST syntax. We plan to propose it upstream to PostgREST.
400+
> This is a SmoothDB extension to PostgREST syntax. We plan to propose it to PostgREST.
401401
402-
SmoothDB supports recursive queries on self-referential tables and views using the `start` and `recurse` operators. These generate PostgreSQL recursive CTEs with automatic cycle detection.
403-
404-
#### Basic Usage
405-
406-
Given a table with a parent pointer (e.g. `employees.manager_id → employees.id`):
402+
SmoothDB walks self-referential tables (and separate edge tables) with the `start`/`recurse` operators, generating PostgreSQL recursive CTEs with automatic cycle detection.
407403

408404
```http
409405
GET /api/testdb/employees?id=start.1&manager_id=recurse.3 HTTP/1.1
410406
```
411407

412-
This returns the row with `id=1` and all descendants up to 3 levels deep, following `manager_id` back to `id`.
408+
Returns row `id=1` and its descendants up to 3 levels deep, following `manager_id → id`. Use `recurse.all` (or bare `recurse`) for unlimited depth (capped by `MaxRecursiveDepth`), and `after` instead of `start` to exclude the seed row.
413409

414-
Use `recurse.all` (or bare `recurse`) for unlimited depth (capped by `MaxRecursiveDepth`):
410+
**Result vs. traversal filters.** A plain filter restricts the *result*: the whole subtree is walked, then non-matching rows are dropped. Prefix a filter with `walk.` to prune the *traversal* instead — a non-matching node, and everything beyond it, is skipped:
415411

416412
```http
417-
GET /api/testdb/employees?id=start.1&manager_id=recurse.all HTTP/1.1
413+
GET /api/testdb/employees?id=start.1&manager_id=recurse.all&is_active=is.true HTTP/1.1 # keep only active rows
414+
GET /api/testdb/employees?id=start.1&manager_id=recurse.all&walk.is_active=is.true HTTP/1.1 # stop walking at inactive nodes
418415
```
419416

420-
#### Excluding the Root
421-
422-
Use `after` instead of `start` to exclude the seed row from results:
417+
Standard parameters (`select`, `order`, `limit`, …) apply to the result set. The pseudo-column `__depth` is selectable to get each row's traversal depth (seed = 0), and related resources can be embedded:
423418

424419
```http
425-
GET /api/testdb/employees?id=after.1&manager_id=recurse.all HTTP/1.1
420+
GET /api/testdb/employees?id=start.1&manager_id=recurse.all&select=id,name,__depth,tasks(title)&order=__depth HTTP/1.1
426421
```
427422

428-
#### Combining with Standard Parameters
429-
430-
All standard query parameters work on the recursive result set:
431-
432-
```http
433-
GET /api/testdb/employees?id=start.1&manager_id=recurse.all&is_active=is.true&select=id,name&order=name.asc&limit=10 HTTP/1.1
434-
```
435-
436-
Filters are applied at every level of the recursion, pruning entire branches.
437-
438-
#### Multi-Table Traversal with `via`
439-
440-
For graph traversal through a separate edge/relationship table, use the `via` operator. Given a `documents` table and a `relationships` table with `src_id` and `dst_id` columns:
423+
**Edge tables (`via`).** Traverse a graph through a separate edge table with source/target columns. Add the `!both` hint to follow edges in either direction; filter which edges to follow with the standard `table.column` syntax (`eq`, `in`, `or`, …):
441424

442425
```http
443426
GET /api/testdb/documents?id=after.1&id=recurse.all&relationships=via(src_id,dst_id) HTTP/1.1
427+
GET /api/testdb/documents?id=after.1&id=recurse.all&relationships=via!both(src_id,dst_id)&relationships.rel_type=in.(contains,references) HTTP/1.1
444428
```
445429

446-
This traverses all documents reachable from document 1 through the `relationships` table, excluding the root.
447-
448-
Filter which edges to follow using the standard PostgREST `table.column` prefix syntax:
449-
450-
```http
451-
GET /api/testdb/documents?id=after.1&id=recurse.all&relationships=via(src_id,dst_id)&relationships.rel_type=eq.contains HTTP/1.1
452-
```
453-
454-
Full PostgREST filter syntax is supported on the edge table, including `or` and `in`:
455-
456-
```http
457-
GET /api/testdb/documents?id=after.1&id=recurse.all&relationships=via(src_id,dst_id)&relationships.rel_type=in.(contains,references) HTTP/1.1
458-
```
430+
Embedding is not supported together with `via` traversal.
459431

460432
## Example for using SmoothDB in your application
461433

database/querybuilder.go

Lines changed: 110 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,7 +1046,7 @@ func buildAfterSelect(query, from, joins, whereClause, groupByClause, orderClaus
10461046
const defaultMaxRecursiveDepth = 100
10471047

10481048
func buildRecursiveSelect(table, schema string, parts *QueryParts, options *QueryOptions,
1049-
selectClause, mainWhere, orderClause string,
1049+
selectClause, mainWhere, orderClause, joins string,
10501050
valueList []any, info *SchemaInfo) (string, []any, error) {
10511051

10521052
rec := parts.recursive
@@ -1060,6 +1060,26 @@ func buildRecursiveSelect(table, schema string, parts *QueryParts, options *Quer
10601060
if dbe != nil && dbe.config.MaxRecursiveDepth == 0 {
10611061
return "", nil, &ParseError{"recursive queries are disabled"}
10621062
}
1063+
1064+
// Embedding (LEFT JOIN LATERAL) composes with single-table recursion only.
1065+
// Via-mode embedding would fight the DISTINCT/min-depth dedup, so reject it cleanly.
1066+
if joins != "" && rec.ViaTable != "" {
1067+
return "", nil, &ParseError{"embedding is not supported with via() recursion"}
1068+
}
1069+
1070+
// Reject __path (internal cycle-tracking array) as a selected field; __depth is selectable.
1071+
for _, sf := range parts.selectFields {
1072+
if sf.field.name == "__path" {
1073+
return "", nil, &ParseError{"__path is internal"}
1074+
}
1075+
}
1076+
depthSelected := false
1077+
for _, sf := range parts.selectFields {
1078+
if sf.field.name == "__depth" {
1079+
depthSelected = true
1080+
break
1081+
}
1082+
}
10631083
maxDepth := rec.MaxDepth
10641084
serverMax := defaultMaxRecursiveDepth
10651085
if dbe != nil && dbe.config.MaxRecursiveDepth > 0 {
@@ -1085,6 +1105,16 @@ func buildRecursiveSelect(table, schema string, parts *QueryParts, options *Quer
10851105
nmarker = len(valueList)
10861106
}
10871107

1108+
// Build the walk-prune WHERE (walk.* filters) against the main table.
1109+
// Marker order stays via -> walk -> start -> depth.
1110+
var walkWhere string
1111+
if rec.WalkConditions != nil {
1112+
var walkValues []any
1113+
walkWhere, walkValues = whereClause(table, schema, "", rec.WalkConditions, nmarker, BuildStack{info: info})
1114+
valueList = append(valueList, walkValues...)
1115+
nmarker = len(valueList)
1116+
}
1117+
10881118
var q strings.Builder
10891119

10901120
// --- CTE ---
@@ -1093,15 +1123,15 @@ func buildRecursiveSelect(table, schema string, parts *QueryParts, options *Quer
10931123
if rec.ViaTable == "" {
10941124
// --- Single-table mode ---
10951125

1096-
// Base case — when ExcludeStart is true (after operator), skip mainWhere on the
1097-
// seed row so it remains a traversal anchor even if it doesn't match the filter.
1126+
// Base case — when ExcludeStart is true (after operator), skip walk filters on
1127+
// the seed row so it remains a traversal anchor even if it doesn't match them.
10981128
q.WriteString("SELECT " + qtable + ".*, 0 AS __depth, ARRAY[" + qtable + "." + startField + "] AS __path")
10991129
q.WriteString(" FROM " + qtable)
11001130
nmarker++
11011131
q.WriteString(" WHERE " + qtable + "." + startField + " = $" + strconv.Itoa(nmarker))
11021132
valueList = append(valueList, rec.StartValue)
1103-
if mainWhere != "" && !rec.ExcludeStart {
1104-
q.WriteString(" AND " + mainWhere)
1133+
if walkWhere != "" && !rec.ExcludeStart {
1134+
q.WriteString(" AND " + walkWhere)
11051135
}
11061136

11071137
q.WriteString(" UNION ALL ")
@@ -1114,8 +1144,8 @@ func buildRecursiveSelect(table, schema string, parts *QueryParts, options *Quer
11141144
q.WriteString(" WHERE " + cteName + ".__depth < $" + strconv.Itoa(nmarker))
11151145
valueList = append(valueList, maxDepth)
11161146
q.WriteString(" AND NOT " + qtable + "." + startField + " = ANY(" + cteName + ".__path)")
1117-
if mainWhere != "" {
1118-
q.WriteString(" AND " + mainWhere)
1147+
if walkWhere != "" {
1148+
q.WriteString(" AND " + walkWhere)
11191149
}
11201150
} else {
11211151
// --- Multi-table (via) mode ---
@@ -1127,32 +1157,39 @@ func buildRecursiveSelect(table, schema string, parts *QueryParts, options *Quer
11271157
viaTo := quote(rec.ViaToCol)
11281158

11291159
// Base case: the start node itself — when ExcludeStart is true (after operator),
1130-
// skip mainWhere so the seed remains a traversal anchor.
1160+
// skip walk filters so the seed remains a traversal anchor.
11311161
q.WriteString("SELECT " + qtable + ".*, 0 AS __depth, ARRAY[" + qtable + "." + startField + "] AS __path")
11321162
q.WriteString(" FROM " + qtable)
11331163
nmarker++
11341164
q.WriteString(" WHERE " + qtable + "." + startField + " = $" + strconv.Itoa(nmarker))
11351165
valueList = append(valueList, rec.StartValue)
1136-
if mainWhere != "" && !rec.ExcludeStart {
1137-
q.WriteString(" AND " + mainWhere)
1166+
if walkWhere != "" && !rec.ExcludeStart {
1167+
q.WriteString(" AND " + walkWhere)
11381168
}
11391169

11401170
q.WriteString(" UNION ALL ")
11411171

11421172
// Recursive step: follow edges from previously found docs
11431173
q.WriteString("SELECT " + qtable + ".*, " + cteName + ".__depth + 1, " + cteName + ".__path || " + qtable + "." + startField)
11441174
q.WriteString(" FROM " + qtable)
1145-
q.WriteString(" INNER JOIN " + qvia + " ON " + qvia + "." + viaTo + " = " + qtable + "." + startField)
1146-
q.WriteString(" INNER JOIN " + cteName + " ON " + qvia + "." + viaFrom + " = " + cteName + "." + startField)
1175+
if rec.ViaBidirectional {
1176+
// via!both: follow edges in either direction. The new node (qtable) sits on
1177+
// one end of the edge, the known node (cte) on the opposite end.
1178+
q.WriteString(" INNER JOIN " + qvia + " ON " + qvia + "." + viaTo + " = " + qtable + "." + startField + " OR " + qvia + "." + viaFrom + " = " + qtable + "." + startField)
1179+
q.WriteString(" INNER JOIN " + cteName + " ON (" + qvia + "." + viaFrom + " = " + cteName + "." + startField + " AND " + qvia + "." + viaTo + " = " + qtable + "." + startField + ") OR (" + qvia + "." + viaTo + " = " + cteName + "." + startField + " AND " + qvia + "." + viaFrom + " = " + qtable + "." + startField + ")")
1180+
} else {
1181+
q.WriteString(" INNER JOIN " + qvia + " ON " + qvia + "." + viaTo + " = " + qtable + "." + startField)
1182+
q.WriteString(" INNER JOIN " + cteName + " ON " + qvia + "." + viaFrom + " = " + cteName + "." + startField)
1183+
}
11471184
nmarker++
11481185
q.WriteString(" WHERE " + cteName + ".__depth < $" + strconv.Itoa(nmarker))
11491186
valueList = append(valueList, maxDepth)
11501187
q.WriteString(" AND NOT " + qtable + "." + startField + " = ANY(" + cteName + ".__path)")
11511188
if viaWhere != "" {
11521189
q.WriteString(" AND " + viaWhere)
11531190
}
1154-
if mainWhere != "" {
1155-
q.WriteString(" AND " + mainWhere)
1191+
if walkWhere != "" {
1192+
q.WriteString(" AND " + walkWhere)
11561193
}
11571194
}
11581195

@@ -1163,12 +1200,8 @@ func buildRecursiveSelect(table, schema string, parts *QueryParts, options *Quer
11631200
tablePrefix := qtable + "."
11641201
ctePrefix := cteName + "."
11651202

1166-
// In via mode, use DISTINCT to deduplicate nodes reachable by multiple paths
1167-
selectKeyword := "SELECT "
1168-
if rec.ViaTable != "" {
1169-
selectKeyword = "SELECT DISTINCT "
1170-
}
1171-
1203+
// Build the SELECT list (without keyword); remember whether it was "*".
1204+
var sel string
11721205
if selectClause == "*" {
11731206
// Enumerate actual table columns to exclude internal __depth/__path
11741207
ftable := _s(table, schema)
@@ -1179,25 +1212,72 @@ func buildRecursiveSelect(table, schema string, parts *QueryParts, options *Quer
11791212
cols = append(cols, cteName+"."+quote(colName))
11801213
}
11811214
sort.Strings(cols)
1182-
q.WriteString(selectKeyword + strings.Join(cols, ", ") + " FROM " + cteName)
1215+
sel = strings.Join(cols, ", ")
11831216
} else {
1184-
q.WriteString(selectKeyword + cteName + ".* FROM " + cteName)
1217+
sel = cteName + ".*"
11851218
}
11861219
} else {
1187-
q.WriteString(selectKeyword + cteName + ".* FROM " + cteName)
1220+
sel = cteName + ".*"
11881221
}
11891222
} else {
1190-
outerSelect := strings.ReplaceAll(selectClause, tablePrefix, ctePrefix)
1191-
q.WriteString(selectKeyword + outerSelect + " FROM " + cteName)
1223+
sel = strings.ReplaceAll(selectClause, tablePrefix, ctePrefix)
1224+
}
1225+
1226+
// Embed joins (LEFT JOIN LATERAL) correlate to the base-table alias; rewrite
1227+
// the top-level "table". -> "__recursive". correlation. The lateral's own numbered
1228+
// alias (relationship_1.) is untouched, so the rewrite is surgical. via+embed is
1229+
// rejected by the guard above, so joinsClause is only ever set in single-table mode.
1230+
var joinsClause string
1231+
if joins != "" {
1232+
joinsClause = " " + strings.ReplaceAll(joins, tablePrefix, ctePrefix)
11921233
}
11931234

1194-
// Optionally exclude the seed row
1235+
// Plain filters become a result WHERE on the outer query (composed with the
1236+
// ExcludeStart seed-skip); walk.* filters already pruned the CTE arms above.
1237+
var outerWhere string
11951238
if rec.ExcludeStart {
1196-
q.WriteString(" WHERE __depth > 0")
1239+
outerWhere = "__depth > 0"
1240+
}
1241+
if mainWhere != "" {
1242+
resultWhere := strings.ReplaceAll(mainWhere, tablePrefix, ctePrefix)
1243+
if outerWhere != "" {
1244+
outerWhere += " AND "
1245+
}
1246+
outerWhere += resultWhere
1247+
}
1248+
1249+
if depthSelected && rec.ViaTable != "" {
1250+
// via min-depth dedup: SELECT DISTINCT ON (node) ... ORDER BY node, __depth
1251+
// reports the shallowest depth per node. Single-table trees skip this — they
1252+
// can't reach a node at two depths (the __path guard keeps edges unique), so a
1253+
// plain SELECT below is fine. The wrapper also isolates min-depth selection from
1254+
// any user ORDER BY appended below.
1255+
q.WriteString("SELECT * FROM (SELECT DISTINCT ON (" + ctePrefix + startField + ") " + sel + " FROM " + cteName)
1256+
if outerWhere != "" {
1257+
q.WriteString(" WHERE " + outerWhere)
1258+
}
1259+
q.WriteString(" ORDER BY " + ctePrefix + startField + ", " + cteName + ".__depth) \"__dedup\"")
1260+
} else {
1261+
// In via mode, DISTINCT deduplicates nodes reachable by multiple paths.
1262+
selectKeyword := "SELECT "
1263+
if rec.ViaTable != "" {
1264+
selectKeyword = "SELECT DISTINCT "
1265+
}
1266+
q.WriteString(selectKeyword + sel + " FROM " + cteName + joinsClause)
1267+
if outerWhere != "" {
1268+
q.WriteString(" WHERE " + outerWhere)
1269+
}
11971270
}
11981271

11991272
if orderClause != "" {
1200-
outerOrder := strings.ReplaceAll(orderClause, tablePrefix, ctePrefix)
1273+
// When the min-depth dedup wrapper is in play the outer query selects from the
1274+
// "__dedup" subquery, so a user ORDER BY must target that alias (and the column
1275+
// must be in the select list); otherwise it references the CTE directly.
1276+
orderPrefix := ctePrefix
1277+
if depthSelected && rec.ViaTable != "" {
1278+
orderPrefix = quote("__dedup") + "."
1279+
}
1280+
outerOrder := strings.ReplaceAll(orderClause, tablePrefix, orderPrefix)
12011281
q.WriteString(" ORDER BY " + outerOrder)
12021282
}
12031283

@@ -1345,7 +1425,7 @@ func (DirectQueryBuilder) BuildSelect(table string, parts *QueryParts, options *
13451425
return "", nil, &ParseError{"aggregate functions cannot be used with recursive queries"}
13461426
}
13471427
return buildRecursiveSelect(table, schema, parts, options,
1348-
selectClause, whereClause, orderClause, valueList, info)
1428+
selectClause, whereClause, orderClause, joins, valueList, info)
13491429
}
13501430

13511431
query := "SELECT " + selectClause
@@ -1381,7 +1461,7 @@ func (QueryWithJSON) BuildSelect(table string, parts *QueryParts, options *Query
13811461
return "", nil, &ParseError{"aggregate functions cannot be used with recursive queries"}
13821462
}
13831463
return buildRecursiveSelect(table, schema, parts, options,
1384-
selectClause, whereClause, orderClause, valueList, info)
1464+
selectClause, whereClause, orderClause, joins, valueList, info)
13851465
}
13861466

13871467
query := "SELECT "

0 commit comments

Comments
 (0)