Skip to content

Commit e7c96a0

Browse files
committed
feat: recurse!up ancestor walk; fix via order-by __depth
1 parent 3d9d71e commit e7c96a0

4 files changed

Lines changed: 140 additions & 4 deletions

File tree

CHANGELOG.md

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

3+
## 0.7.2 - 2026-06-30
4+
5+
### Added
6+
* `recurse!up` operator for single-table recursion — walks the FK toward ancestors instead of descendants (e.g. `parent_id=recurse!up.all` returns a node's ancestor chain). Rejected with `via()`, where the same reversal is done by swapping the edge columns.
7+
8+
### Fixed
9+
* Ordering a `via()` recursive query by `__depth` without selecting it no longer 500s with "ORDER BY expressions must appear in select list"; `__depth` is now surfaced in the projection and routed through the min-depth dedup wrapper.
10+
311
## 0.7.1 - 2026-06-18
412

513
### Fixed

database/querybuilder.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,6 +1094,22 @@ func buildRecursiveSelect(table, schema string, parts *QueryParts, options *Quer
10941094
break
10951095
}
10961096
}
1097+
// via's min-depth dedup emits SELECT DISTINCT, whose ORDER BY columns must all appear in
1098+
// the select list. If the caller orders by __depth without selecting it we surface __depth
1099+
// in the projection (below) and route through the dedup wrapper — otherwise Postgres 500s
1100+
// with "ORDER BY expressions must appear in select list". Single-table mode uses a plain
1101+
// SELECT and can already order by an unselected __depth, so it needs no such handling.
1102+
depthOrdered := false
1103+
for _, of := range parts.orderFields {
1104+
if of.field.name == "__depth" {
1105+
depthOrdered = true
1106+
break
1107+
}
1108+
}
1109+
exposeDepth := rec.ViaTable != "" && depthOrdered && !depthSelected
1110+
if exposeDepth {
1111+
depthSelected = true
1112+
}
10971113
maxDepth := rec.MaxDepth
10981114
serverMax := defaultMaxRecursiveDepth
10991115
if dbe != nil && dbe.config.MaxRecursiveDepth > 0 {
@@ -1150,10 +1166,19 @@ func buildRecursiveSelect(table, schema string, parts *QueryParts, options *Quer
11501166

11511167
q.WriteString(" UNION ALL ")
11521168

1153-
// Recursive step
1169+
// Recursive step. Default (down): join new.recurseField = prev.startField - follow the
1170+
// FK backwards to descendants (rows whose FK points at a known node). recurse!up reverses
1171+
// the same two fields to new.startField = prev.recurseField — follow the FK forwards to
1172+
// ancestors (the row the known node's FK points at). Base case and the __path cycle guard
1173+
// (keyed on startField) are identical for both directions.
11541174
q.WriteString("SELECT " + qtable + ".*" + rowCol + ", " + cteName + ".__depth + 1, " + cteName + ".__path || " + qtable + "." + startField)
11551175
q.WriteString(" FROM " + qtable)
1156-
q.WriteString(" INNER JOIN " + cteName + " ON " + qtable + "." + recurseField + " = " + cteName + "." + startField)
1176+
// down: new.recurseField = prev.startField; up swaps the two fields.
1177+
newField, prevField := recurseField, startField
1178+
if rec.RecurseUp {
1179+
newField, prevField = startField, recurseField
1180+
}
1181+
q.WriteString(" INNER JOIN " + cteName + " ON " + qtable + "." + newField + " = " + cteName + "." + prevField)
11571182
nmarker++
11581183
q.WriteString(" WHERE " + cteName + ".__depth < $" + strconv.Itoa(nmarker))
11591184
valueList = append(valueList, maxDepth)
@@ -1236,6 +1261,17 @@ func buildRecursiveSelect(table, schema string, parts *QueryParts, options *Quer
12361261
} else {
12371262
sel = strings.ReplaceAll(selectClause, tablePrefix, ctePrefix)
12381263
}
1264+
// Surface __depth for an order-by that didn't select it (see exposeDepth above). The
1265+
// cteName.* form already carries __depth, so only the enumerated/explicit lists need it.
1266+
// NOTE: this deliberately leaks __depth into the result projection — a `via(...)` walk
1267+
// ordered by an unselected __depth (or `select=*`, whose enumeration otherwise excludes
1268+
// __depth) returns it as an extra column. Single-table mode uses a plain SELECT that can
1269+
// order by an unselected column without surfacing it, so the two modes are asymmetric
1270+
// here. The leak is the price of routing via through the min-depth dedup wrapper, which
1271+
// requires every ORDER BY column to be in the DISTINCT select list.
1272+
if exposeDepth && sel != cteName+".*" {
1273+
sel += ", " + ctePrefix + quote("__depth")
1274+
}
12391275

12401276
// Embed joins (LEFT JOIN LATERAL) correlate to the base-table alias; rewrite
12411277
// the top-level "table". -> "__recursive". correlation. The lateral's own numbered

database/requestparser.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ type RecursiveInfo struct {
8181
RecurseField string // FK column to follow (e.g. "manager_id")
8282
MaxDepth int // max depth; -1 = unlimited (capped by server max)
8383
ExcludeStart bool // true when "after" operator is used instead of "start"
84+
RecurseUp bool // true for recurse!up — follow the FK toward ancestors (single-table only)
8485
ViaTable string // edge table for multi-table recursion (empty = single-table)
8586
ViaFromCol string // edge table column matching the start value (e.g. "src_id")
8687
ViaToCol string // edge table column linking to main table's start field (e.g. "dst_id")
@@ -1146,15 +1147,18 @@ func (p PostgRestParser) parse(mainTable string, filters Filters) (parts *QueryP
11461147
for _, v := range vv {
11471148
prefix, rest, ok := strings.Cut(v, ".")
11481149
if !ok {
1149-
if v == "recurse" {
1150-
// recurse without depth value — treat as "all"
1150+
if v == "recurse" || v == "recurse!up" {
1151+
// recurse without depth value — treat as "all". The "!up" hint reverses
1152+
// the walk to follow the FK toward ancestors (single-table only); mirrors
1153+
// the via!both grammar.
11511154
if recInfo == nil {
11521155
recInfo = &RecursiveInfo{MaxDepth: -1}
11531156
}
11541157
if recInfo.RecurseField != "" {
11551158
return nil, &ParseError{"only one 'recurse' operator is allowed"}
11561159
}
11571160
recInfo.RecurseField = k
1161+
recInfo.RecurseUp = (v == "recurse!up")
11581162
} else if (strings.HasPrefix(v, "via(") || strings.HasPrefix(v, "via!")) && strings.HasSuffix(v, ")") {
11591163
// via(from_col,to_col) — directed (default)
11601164
// via!both(from_col,to_col) — undirected (follow edges either way).
@@ -1184,6 +1188,17 @@ func (p PostgRestParser) parse(mainTable string, filters Filters) (parts *QueryP
11841188
}
11851189
continue
11861190
}
1191+
// "!up" hint reverses the walk toward ancestors (single-table only). It is
1192+
// only meaningful on 'recurse'; reject it elsewhere so a typo like
1193+
// "start!up.5" fails loudly instead of silently parsing as "start.5".
1194+
recurseUp := false
1195+
if strings.HasSuffix(prefix, "!up") {
1196+
prefix = strings.TrimSuffix(prefix, "!up")
1197+
recurseUp = true
1198+
if prefix != "recurse" {
1199+
return nil, &ParseError{"'!up' is only valid on 'recurse'"}
1200+
}
1201+
}
11871202
switch prefix {
11881203
case "start", "after":
11891204
if recInfo == nil {
@@ -1203,6 +1218,7 @@ func (p PostgRestParser) parse(mainTable string, filters Filters) (parts *QueryP
12031218
return nil, &ParseError{"only one 'recurse' operator is allowed"}
12041219
}
12051220
recInfo.RecurseField = k
1221+
recInfo.RecurseUp = recurseUp
12061222
if rest == "all" {
12071223
recInfo.MaxDepth = -1
12081224
} else {
@@ -1225,6 +1241,9 @@ func (p PostgRestParser) parse(mainTable string, filters Filters) (parts *QueryP
12251241
if recInfo.ViaTable != "" && recInfo.StartField != recInfo.RecurseField {
12261242
return nil, &ParseError{"'via' requires 'start' and 'recurse' to use the same field"}
12271243
}
1244+
if recInfo.ViaTable != "" && recInfo.RecurseUp {
1245+
return nil, &ParseError{"'recurse!up' is single-table only; reverse a 'via' walk by swapping its columns"}
1246+
}
12281247
// Remove start/recurse/via from filters so they don't become WHERE clauses
12291248
delete(filters, recInfo.StartField)
12301249
if recInfo.StartField != recInfo.RecurseField {

test/recursive/recursive_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,3 +376,76 @@ func TestViaEmbedRejected(t *testing.T) {
376376
}
377377
test.Execute(t, testConfig(), tests)
378378
}
379+
380+
// TestRecurseUp checks recurse!up — the single-table walk reversed to follow the FK
381+
// toward ANCESTORS. Junior Dev(5) -> Senior Dev(4) -> VP Eng(2) -> CEO(1).
382+
func TestRecurseUp(t *testing.T) {
383+
tests := []test.Test{
384+
{
385+
Description: "recurse!up.all from a leaf — the full ancestor chain to the root",
386+
Query: "/tree_node?id=start.5&parent_id=recurse!up.all&select=id,name&order=__depth",
387+
Expected: `[{"id":5,"name":"Junior Dev"},{"id":4,"name":"Senior Dev"},{"id":2,"name":"VP Eng"},{"id":1,"name":"CEO"}]`,
388+
Status: 200,
389+
},
390+
{
391+
Description: "recurse!up depth cap — only two levels up",
392+
Query: "/tree_node?id=start.5&parent_id=recurse!up.2&select=id,name&order=__depth",
393+
Expected: `[{"id":5,"name":"Junior Dev"},{"id":4,"name":"Senior Dev"},{"id":2,"name":"VP Eng"}]`,
394+
Status: 200,
395+
},
396+
{
397+
Description: "recurse!up from the root — just the root itself",
398+
Query: "/tree_node?id=start.1&parent_id=recurse!up.all&select=id,name",
399+
Expected: `[{"id":1,"name":"CEO"}]`,
400+
Status: 200,
401+
},
402+
{
403+
Description: "recurse!up with after — excludes the seed, returns its ancestors",
404+
Query: "/tree_node?id=after.5&parent_id=recurse!up.all&select=id,name&order=__depth",
405+
Expected: `[{"id":4,"name":"Senior Dev"},{"id":2,"name":"VP Eng"},{"id":1,"name":"CEO"}]`,
406+
Status: 200,
407+
},
408+
{
409+
// Result filter keeps only active ancestors; the walk still passes THROUGH the
410+
// inactive 101 to reach 100. Mirrors the down-walk result-filter test.
411+
Description: "recurse!up with is_active result filter keeps active ancestors past an inactive one",
412+
Query: "/tree_node?id=start.102&parent_id=recurse!up.all&is_active=is.true&select=id,name&order=__depth",
413+
Expected: `[{"id":102,"name":"Remote Worker"},{"id":100,"name":"Region"}]`,
414+
Status: 200,
415+
},
416+
{
417+
Description: "recurse!up __depth reports distance to each ancestor",
418+
Query: "/tree_node?id=start.5&parent_id=recurse!up.all&select=id,__depth&order=__depth",
419+
Expected: `[{"id":5,"__depth":0},{"id":4,"__depth":1},{"id":2,"__depth":2},{"id":1,"__depth":3}]`,
420+
Status: 200,
421+
},
422+
{
423+
Description: "'!up' on start is rejected (only valid on recurse)",
424+
Query: "/tree_node?id=start!up.5&parent_id=recurse.all&select=id",
425+
Expected: `{"code":"","details":null,"hint":"","message":"'!up' is only valid on 'recurse'","position":0,"subsystem":"network"}`,
426+
Status: 400,
427+
},
428+
{
429+
Description: "recurse!up is rejected with via (reverse a via walk by swapping its columns)",
430+
Query: "/doc?id=start.1&id=recurse!up.all&doc_rel=via(src_id,dst_id)&select=id",
431+
Expected: `{"code":"","details":null,"hint":"","message":"'recurse!up' is single-table only; reverse a 'via' walk by swapping its columns","position":0,"subsystem":"network"}`,
432+
Status: 400,
433+
},
434+
}
435+
test.Execute(t, testConfig(), tests)
436+
}
437+
438+
// TestViaOrderByDepthUnselected checks that ordering a via() walk by __depth WITHOUT
439+
// selecting it works (surfacing __depth in the projection) instead of 500ing with
440+
// "ORDER BY expressions must appear in select list" from the min-depth SELECT DISTINCT.
441+
func TestViaOrderByDepthUnselected(t *testing.T) {
442+
tests := []test.Test{
443+
{
444+
Description: "via order=__depth without selecting __depth — surfaces __depth, no 500",
445+
Query: "/doc?id=start.1&id=recurse.all&doc_rel=via(src_id,dst_id)&select=id&order=__depth,id",
446+
Expected: `[{"id":1,"__depth":0},{"id":2,"__depth":1},{"id":3,"__depth":1},{"id":4,"__depth":1},{"id":5,"__depth":2},{"id":6,"__depth":2}]`,
447+
Status: 200,
448+
},
449+
}
450+
test.Execute(t, testConfig(), tests)
451+
}

0 commit comments

Comments
 (0)