diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eea016a..045c7347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/rupg-exec/src/lib.rs b/crates/rupg-exec/src/lib.rs index 49407c6f..5655e2ed 100644 --- a/crates/rupg-exec/src/lib.rs +++ b/crates/rupg-exec/src/lib.rs @@ -800,7 +800,7 @@ fn free_work(query: &Query) -> Vec { } /// `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(()), diff --git a/crates/rupg-exec/src/scan.rs b/crates/rupg-exec/src/scan.rs index cba912eb..fb4bf5cf 100644 --- a/crates/rupg-exec/src/scan.rs +++ b/crates/rupg-exec/src/scan.rs @@ -487,13 +487,24 @@ fn plain_var(expr: &Expr) -> Option { } } -/// 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. @@ -651,7 +662,7 @@ 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>> { let found = conds.iter().find_map(|expr| { let ExprKind::Func(f) = &expr.kind else { return None }; @@ -659,10 +670,10 @@ impl Scan<'_, '_> { 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, diff --git a/crates/rupg-session/src/connection.rs b/crates/rupg-session/src/connection.rs index e4b01470..0191d40a 100644 --- a/crates/rupg-session/src/connection.rs +++ b/crates/rupg-session/src/connection.rs @@ -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() { diff --git a/crates/rupg-session/src/connection/outer_lookups.test b/crates/rupg-session/src/connection/outer_lookups.test new file mode 100644 index 00000000..10759677 --- /dev/null +++ b/crates/rupg-session/src/connection/outer_lookups.test @@ -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