Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ All changes that a user can see are in this file. The version is 0.M.patch, wher
- `VACUUM` and `ANALYZE`, which check their options, relations and columns and then end, because rupg has no statistics yet (#442).
- `CREATE TABLE AS` and `SELECT ... INTO`, which make a table from the columns of a query and fill it with the rows of the query (#443).

### Fixed

- A `LATERAL` relation in a nested loop no longer keeps the rows of every run until the query ends. This stopped the server with no memory in the regression tests `join` and `subselect` (#444).

## 0.1.51 (2026-10-20)

### Added
Expand Down
2 changes: 1 addition & 1 deletion crates/rupg-exec/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -800,7 +800,7 @@ fn free_work(query: &Query) -> Vec<usize> {
}

/// `contain_volatile_functions`: true when the expression calls a volatile function, also in a subquery.
fn volatile(expr: &Expr) -> bool {
pub(crate) fn volatile(expr: &Expr) -> bool {
let volatile = |func: u32| builtin::proc_by_oid(func).is_some_and(|p| p.volatile == b'v');
expr.find(0, &mut |e, _| match &e.kind {
ExprKind::Func(f) if volatile(f.oid) => Some(()),
Expand Down
29 changes: 20 additions & 9 deletions crates/rupg-exec/src/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,13 +487,24 @@ fn plain_var(expr: &Expr) -> Option<Var> {
}
}

/// True when an expression is a column of an outer query, or such a column with another type of the same form.
fn outer_var(expr: &Expr) -> bool {
match &expr.kind {
ExprKind::Var(var) => var.levels_up > 0,
ExprKind::Relabel(arg, _) => outer_var(arg),
_ => false,
/// True when an expression reads columns of an outer query and nothing of its own query, so that it has one value for each run of the query: a column of an outer query, a constant, a parameter, or a function that is not volatile of such expressions, as `t1.tenthous + 1`. An index of the other side then gives the rows of each run, as an index scan of PostgreSQL does with a parameter of the outer plan.
fn outer_expr(expr: &Expr) -> bool {
fn fixed(expr: &Expr, outer: &mut bool) -> bool {
match &expr.kind {
ExprKind::Var(var) if var.levels_up > 0 => {
*outer = true;
true
}
ExprKind::Const(_) | ExprKind::Param(_) => true,
ExprKind::Relabel(arg, _) | ExprKind::CoerceViaIo(arg, _) => fixed(arg, outer),
ExprKind::Func(f) if !f.retset && !crate::volatile(expr) => {
f.args.iter().all(|arg| fixed(arg, outer))
}
_ => false,
}
}
let mut outer = false;
fixed(expr, &mut outer) && outer
}

/// The parts of a condition that `AND` joins. They are references and not copies, because the cache of the rows of a subquery keeps them by the address of the subquery: a copy that the scan drops can free an address that another subquery then gets.
Expand Down Expand Up @@ -651,18 +662,18 @@ impl Scan<'_, '_> {
Ok(out)
}

/// The rows of a relation that can pass the conditions `conds`, through an index of a column, when a condition is an `=` of the column and a column of an outer query, as in a correlated subquery. The subquery runs for each row of the outer query, and the index makes each run read only the rows with the value of that row, as an index scan with a parameter does. `None` when no condition is such an `=`.
/// The rows of a relation that can pass the conditions `conds`, through an index of a column, when a condition is an `=` of the column and an expression of the columns of an outer query, as in a correlated subquery. The subquery runs for each row of the outer query, and the index makes each run read only the rows with the value of that row, as an index scan with a parameter does. `None` when no condition is such an `=`.
fn lookup(&mut self, relation: usize, conds: &[&Expr]) -> Result<Option<Vec<usize>>> {
let found = conds.iter().find_map(|expr| {
let ExprKind::Func(f) = &expr.kind else { return None };
if !HASH_EQUALS.contains(&f.oid) || f.args.len() != 2 {
return None;
}
match (plain_var(&f.args[0]), plain_var(&f.args[1])) {
(Some(var), None) if var.relation == relation && outer_var(&f.args[1]) => {
(Some(var), None) if var.relation == relation && outer_expr(&f.args[1]) => {
Some((var, &f.args[1]))
}
(None, Some(var)) if var.relation == relation && outer_var(&f.args[0]) => {
(None, Some(var)) if var.relation == relation && outer_expr(&f.args[0]) => {
Some((var, &f.args[0]))
}
_ => None,
Expand Down
6 changes: 6 additions & 0 deletions crates/rupg-session/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3553,6 +3553,12 @@ mod tests {
big_stack(|| script(include_str!("connection/create_database.test")));
}

/// A correlated subquery with an `=` of a column and an expression of the columns of the outer query, which the scan finds through an index of the column: an arithmetic expression, a text concatenation, `EXISTS`, `NULLIF` that gives a null, and a function. `connection/outer_lookups.test` is the output of PostgreSQL 19 for the same script in a new database.
#[test]
fn outer_lookups() {
big_stack(|| script(include_str!("connection/outer_lookups.test")));
}

/// The runs of a `LATERAL` relation in a nested loop: a join that keeps a few rows of many runs, an outer join with runs that give no row, a whole-row reference, two `LATERAL` relations in a chain, and an aggregate over the joined rows. `connection/lateral_runs.test` is the output of PostgreSQL 19 for the same script in a new database.
#[test]
fn lateral_runs() {
Expand Down
43 changes: 43 additions & 0 deletions crates/rupg-session/src/connection/outer_lookups.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
!details
> SET TimeZone = 'UTC'
SET
> CREATE TABLE t (id int, k int, s text)
CREATE TABLE
> INSERT INTO t SELECT g, g % 500, 'v' || g FROM generate_series(1, 2000) g
INSERT 0 2000
> SELECT count(*) FROM t t1 WHERE t1.k = 42 OR t1.k = (SELECT t2.id FROM t t2 WHERE t2.k = t1.id + 1 ORDER BY t2.id LIMIT 1)
fields count:20:8:-1
row 4
SELECT 1
> SELECT t1.id, (SELECT count(*) FROM t t2 WHERE t2.k = t1.id * 2) FROM t t1 WHERE t1.id IN (1, 100, 300, 499) ORDER BY t1.id
fields id@t.1:23:4:-1,count:20:8:-1
row 1,4
row 100,4
row 300,0
row 499,0
SELECT 4
> SELECT t1.id, (SELECT min(t2.s) FROM t t2 WHERE t2.s = 'v' || (t1.id + 1)) FROM t t1 WHERE t1.id < 4 ORDER BY t1.id
fields id@t.1:23:4:-1,min:25:-1:-1
row 1,v2
row 2,v3
row 3,v4
SELECT 3
> SELECT t1.id FROM t t1 WHERE EXISTS (SELECT 1 FROM t t2 WHERE t2.id = t1.k + 1500) AND t1.id < 5 ORDER BY t1.id
fields id@t.1:23:4:-1
row 1
row 2
row 3
row 4
SELECT 4
> SELECT t1.id, (SELECT count(*) FROM t t2 WHERE t2.k = NULLIF(t1.id, 3)) FROM t t1 WHERE t1.id < 5 ORDER BY t1.id
fields id@t.1:23:4:-1,count:20:8:-1
row 1,4
row 2,4
row 3,0
row 4,4
SELECT 4
> SELECT t1.id, (SELECT count(*) FROM t t2 WHERE t2.k = abs(t1.id - 400)) FROM t t1 WHERE t1.id IN (100, 1100) ORDER BY t1.id
fields id@t.1:23:4:-1,count:20:8:-1
row 100,4
row 1100,0
SELECT 2
Loading