Skip to content

Commit 141279b

Browse files
committed
readyset-server: Allow computed local predicates
This allows more predicates referencing a single table within join `ON` clauses to be treated as local to that table, which causes them to be turned into filters between the table and the join. For example, this was previously unsupported because the length predicate became a "global" predicate which is unsupported for the join: ```sql SELECT * FROM t LEFT JOIN v ON t.id = v.id AND length(v.field) > 10; ``` This pattern is not yet allowed *everywhere*, but allows predicates which are binary expressions only referencing a single table, even if they contain computed expressions on columns in that table. Change-Id: I5a8ef482ee5a7e670de6b98e4713abad6a6a6964 Reviewed-on: https://gerrit.readyset.name/c/readyset/+/11069 Tested-by: Buildkite CI Reviewed-by: Vassili Zarouba <[email protected]>
1 parent daf3387 commit 141279b

File tree

3 files changed

+195
-3
lines changed

3 files changed

+195
-3
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
statement ok
2+
create table foo (
3+
id int primary key,
4+
bar_id int,
5+
category text
6+
);
7+
8+
statement ok
9+
create table bar (
10+
id int primary key,
11+
text_value text
12+
);
13+
14+
statement ok
15+
insert into bar (id, text_value) values
16+
(1, 'ok'),
17+
(2, null),
18+
(3, 'toolong'),
19+
(4, 'short'),
20+
(5, 'tiny');
21+
22+
statement ok
23+
insert into foo (id, bar_id, category) values
24+
(1, 1, 'other'),
25+
(2, 2, 'other'),
26+
(3, 3, 'other'),
27+
(4, 4, 'other'),
28+
(5, 5, 'something_else');
29+
30+
# Baseline: IS NOT NULL in ON clause should still attach rows with long values.
31+
query IT rowsort
32+
SELECT
33+
foo.id,
34+
bar.text_value
35+
FROM
36+
foo
37+
LEFT JOIN bar
38+
ON bar.id = foo.bar_id
39+
AND bar.text_value IS NOT NULL
40+
WHERE
41+
foo.category = 'other'
42+
----
43+
1
44+
ok
45+
2
46+
NULL
47+
3
48+
toolong
49+
4
50+
short
51+
52+
# Regression: computed predicate in ON clause should be treated as a right-local filter.
53+
query IT rowsort
54+
SELECT
55+
foo.id,
56+
bar.text_value
57+
FROM
58+
foo
59+
LEFT JOIN bar
60+
ON bar.id = foo.bar_id
61+
AND bar.text_value IS NOT NULL
62+
AND length(bar.text_value) <= 5
63+
WHERE
64+
foo.category = 'other'
65+
----
66+
1
67+
ok
68+
2
69+
NULL
70+
3
71+
NULL
72+
4
73+
short

readyset-psql/tests/integration.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2119,6 +2119,77 @@ async fn trunc_in_trx() {
21192119
shutdown_tx.shutdown().await;
21202120
}
21212121

2122+
#[tokio::test(flavor = "multi_thread")]
2123+
async fn left_join_on_computed_predicate_filters_right_side() {
2124+
let (opts, _handle, shutdown_tx) = setup().await;
2125+
let conn = connect(opts).await;
2126+
2127+
conn.simple_query("CREATE TABLE foo (id INT PRIMARY KEY, bar_id INT, category TEXT);")
2128+
.await
2129+
.unwrap();
2130+
conn.simple_query("CREATE TABLE bar (id INT PRIMARY KEY, text_value TEXT);")
2131+
.await
2132+
.unwrap();
2133+
2134+
conn.simple_query(
2135+
"INSERT INTO bar (id, text_value) VALUES \
2136+
(1, 'ok'), \
2137+
(2, NULL), \
2138+
(3, 'toolong'), \
2139+
(4, 'short'), \
2140+
(5, 'tiny');",
2141+
)
2142+
.await
2143+
.unwrap();
2144+
2145+
conn.simple_query(
2146+
"INSERT INTO foo (id, bar_id, category) VALUES \
2147+
(1, 1, 'other'), \
2148+
(2, 2, 'other'), \
2149+
(3, 3, 'other'), \
2150+
(4, 4, 'other'), \
2151+
(5, 5, 'something_else');",
2152+
)
2153+
.await
2154+
.unwrap();
2155+
2156+
sleep().await;
2157+
2158+
let rows = conn
2159+
.query(
2160+
"SELECT \
2161+
foo.id, \
2162+
bar.text_value \
2163+
FROM foo \
2164+
LEFT JOIN bar \
2165+
ON bar.id = foo.bar_id \
2166+
AND bar.text_value IS NOT NULL \
2167+
AND length(bar.text_value) <= 5 \
2168+
WHERE foo.category = 'other' \
2169+
ORDER BY foo.id",
2170+
&[],
2171+
)
2172+
.await
2173+
.unwrap();
2174+
2175+
let results: Vec<(i32, Option<String>)> = rows
2176+
.into_iter()
2177+
.map(|row| (row.get(0), row.get(1)))
2178+
.collect();
2179+
2180+
assert_eq!(
2181+
results,
2182+
vec![
2183+
(1, Some("ok".to_string())),
2184+
(2, None),
2185+
(3, None),
2186+
(4, Some("short".to_string()))
2187+
]
2188+
);
2189+
2190+
shutdown_tx.shutdown().await;
2191+
}
2192+
21222193
mod http_tests {
21232194
use super::*;
21242195
#[tokio::test(flavor = "multi_thread")]

readyset-server/src/controller/sql/query_graph.rs

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use readyset_errors::{
1414
unsupported, unsupported_err, ReadySetResult,
1515
};
1616
use readyset_sql::analysis::visit_mut::{walk_expr, VisitorMut};
17-
use readyset_sql::analysis::{is_aggregate, ReferredColumns};
17+
use readyset_sql::analysis::{is_aggregate, ReferredColumns, ReferredTables};
1818
use readyset_sql::ast::{
1919
self, BinaryOperator, Column, Expr, FieldDefinitionExpr, FieldReference, FunctionExpr, InValue,
2020
ItemPlaceholder, JoinConstraint, JoinOperator, JoinRightSide, LimitClause, Literal, NullOrder,
@@ -690,9 +690,18 @@ fn classify_conditionals(
690690
local.entry(table.clone()).or_default().push(ce.clone());
691691
}
692692

693-
// Comparisons between computed columns and literals: Store globally
693+
// Any other binary predicate: if it mentions exactly one table, it is a local
694+
// predicate for that table; otherwise, it is a global predicate.
694695
_ => {
695-
global.push(ce.clone());
696+
let mut tables = ce.referred_tables().into_iter();
697+
match (tables.next(), tables.next()) {
698+
(Some(table), None) => {
699+
local.entry(table).or_default().push(ce.clone());
700+
}
701+
_ => {
702+
global.push(ce.clone());
703+
}
704+
}
696705
}
697706
}
698707
} else {
@@ -1838,6 +1847,45 @@ mod tests {
18381847
}
18391848
}
18401849

1850+
#[test]
1851+
fn computed_right_local_predicates_in_left_join() {
1852+
let qg = make_query_graph(
1853+
"SELECT foo.id, bar.text_value \
1854+
FROM foo \
1855+
LEFT JOIN bar \
1856+
ON bar.id = foo.bar_id \
1857+
AND bar.text_value IS NOT NULL \
1858+
AND length(bar.text_value) <= 5 \
1859+
WHERE foo.category = 'other'",
1860+
);
1861+
1862+
let join = qg.edges.get(&("foo".into(), "bar".into())).unwrap();
1863+
1864+
match join {
1865+
QueryGraphEdge::LeftJoin {
1866+
on,
1867+
left_local_preds,
1868+
right_local_preds,
1869+
global_preds,
1870+
params,
1871+
} => {
1872+
assert_eq!(
1873+
*on,
1874+
vec![JoinPredicate {
1875+
left: Column::from("foo.bar_id"),
1876+
right: Column::from("bar.id"),
1877+
}]
1878+
);
1879+
assert!(left_local_preds.is_empty());
1880+
// Both the IS NOT NULL and the length() predicate should be treated as right-local.
1881+
assert_eq!(right_local_preds.len(), 2);
1882+
assert!(global_preds.is_empty());
1883+
assert!(params.is_empty());
1884+
}
1885+
QueryGraphEdge::Join { .. } => panic!("Expected left join, got {join:?}"),
1886+
}
1887+
}
1888+
18411889
mod view_key {
18421890
use super::*;
18431891

0 commit comments

Comments
 (0)