Skip to content

Commit ea5f857

Browse files
authored
Merge pull request #84 from ilyakooo0/push-quqskwyyyqnm
fix: where filter on source relations silently ignored or panics
2 parents 34ab6dc + 5ee51a3 commit ea5f857

2 files changed

Lines changed: 142 additions & 5 deletions

File tree

crates/knot-compiler/src/codegen.rs

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8526,6 +8526,37 @@ impl Codegen {
85268526
})
85278527
}
85288528

8529+
/// Does a `where` in these statements filter on `name`'s FIELDS?
8530+
///
8531+
/// Only a per-row reading gives such a `where` any meaning: as a guard over
8532+
/// the whole relation, `u.age` is the FIRST row's age (field access on a
8533+
/// relation delegates to its first element), so the guard either waves every
8534+
/// row through or drops the block entirely. Seeing one therefore settles
8535+
/// `x <- *rel` as a comprehension bind even when the name is also used as a
8536+
/// whole value — `yield u` yields the ROW.
8537+
///
8538+
/// A `where` that uses `name` as a value (`where count people > 3`) is a
8539+
/// real guard on the relation and is deliberately not counted. The scan
8540+
/// stops at a statement that rebinds `name`, past which the uses belong to
8541+
/// a different binding.
8542+
fn where_filters_row_fields(stmts: &[ast::Stmt], name: &str) -> bool {
8543+
for stmt in stmts {
8544+
match &stmt.node {
8545+
ast::StmtKind::Where { cond } => {
8546+
if expr_refs_var(cond, name) && !expr_uses_var_as_value(cond, name) {
8547+
return true;
8548+
}
8549+
}
8550+
ast::StmtKind::Bind { pat, .. } | ast::StmtKind::Let { pat, .. }
8551+
if pat_bound_names(pat).iter().any(|n| n == name) => {
8552+
return false;
8553+
}
8554+
_ => {}
8555+
}
8556+
}
8557+
false
8558+
}
8559+
85298560
/// Like `do_block_is_comprehension`, but also admits `groupBy`
85308561
/// statements: a do-block ending in `yield` whose other statements are
85318562
/// all Bind/Where/Let/GroupBy compiles through the relational loop
@@ -8830,9 +8861,30 @@ impl Codegen {
88308861
// yield (filter (\p -> p.age > 65) people) }`
88318862
// passes the name on as the whole relation, while the
88328863
// comprehension above only ever reads fields off it. Iterate
8833-
// exactly when every use is a field access on the name —
8834-
// referenced, but never as a value — which is meaningless
8835-
// under whole-relation semantics anyway ("first row's age").
8864+
// when every use is a field access on the name — referenced,
8865+
// but never as a value — which is meaningless under
8866+
// whole-relation semantics anyway ("first row's age").
8867+
//
8868+
// That rule alone misses the most ordinary comprehension of
8869+
// all, `yield x` (yield the ROW):
8870+
//
8871+
// &adults = do { u <- *users; where u.age >= 25; yield u }
8872+
//
8873+
// `yield u` uses the name as a value, so the block fell back
8874+
// to whole-relation semantics and the `where` became a guard
8875+
// on the FIRST row: `u.age >= 25` was true for row 1, the
8876+
// guard passed, and `yield u` handed back the WHOLE relation
8877+
// — every user, filter silently ignored. Flip the predicate
8878+
// to one row 1 fails and the guard drops the whole block to
8879+
// `{}`, so the caller's `count` panicked on a non-relation.
8880+
//
8881+
// A `where` that reads FIELDS off the bound name is itself
8882+
// the proof that the bind iterates: as a whole-relation guard
8883+
// it can only mean "the first row's field", which is never
8884+
// what a filter means. So it settles the reading regardless
8885+
// of how `yield` uses the name. A `where` over the name as a
8886+
// VALUE (`where count people > 3`) is a genuine guard on the
8887+
// relation and keeps the whole-relation reading.
88368888
let comprehension_tail = rhs_is_io_relation_source
88378889
&& Self::do_block_is_comprehension(&stmts[stmt_idx..])
88388890
&& match &pat.node {
@@ -8841,8 +8893,12 @@ impl Codegen {
88418893
ast::ExprKind::Do(stmts[stmt_idx + 1..].to_vec()),
88428894
stmt.span,
88438895
);
8844-
expr_refs_var(&tail, name)
8845-
&& !expr_uses_var_as_value(&tail, name)
8896+
(expr_refs_var(&tail, name)
8897+
&& !expr_uses_var_as_value(&tail, name))
8898+
|| Self::where_filters_row_fields(
8899+
&stmts[stmt_idx + 1..],
8900+
name,
8901+
)
88468902
}
88478903
_ => false,
88488904
};

crates/knot-compiler/tests/regress_knot_issues.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,3 +682,84 @@ main = do
682682
"both derived impls should work, got: {stdout}",
683683
);
684684
}
685+
686+
// ── 13. `where` over a source relation, yielding the whole row ────
687+
688+
/// `x <- *rel` reads either as a comprehension bind (iterate ROWS) or as a
689+
/// whole-relation bind, and codegen picks between them from how the block uses
690+
/// the name. `yield u` — the most ordinary comprehension there is — looks like
691+
/// a whole-relation use, so the block took the guard reading: `where u.age >=
692+
/// 25` asked the FIRST row's age, waved every row through, and `yield u` handed
693+
/// back the entire relation with the filter silently dropped.
694+
const ADULTS: &str = "type User = {name: Text, age: Int}\n\
695+
*users : [User]\n\
696+
&adults = do\n\
697+
\x20 u <- *users\n\
698+
\x20 where u.age >= 25\n\
699+
\x20 yield u\n";
700+
701+
#[test]
702+
fn a_where_filters_the_rows_of_a_source_relation() {
703+
let src = format!(
704+
"{ADULTS}\
705+
main = do\n\
706+
\x20 replace *users = [{{name: \"Alice\", age: 30}}, {{name: \"Bob\", age: 20}}, {{name: \"Carol\", age: 40}}]\n\
707+
\x20 a <- &adults\n\
708+
\x20 forEach a (\\u -> println u.name)\n\
709+
\x20 yield {{}}\n"
710+
);
711+
let (stdout, stderr, ok) = compile_and_run("where_filters_source", &src, &[]);
712+
assert!(ok, "the program must run: {stderr}");
713+
assert!(stdout.contains("Alice"), "a row passing the where must be yielded: {stdout:?}");
714+
assert!(stdout.contains("Carol"), "a row passing the where must be yielded: {stdout:?}");
715+
assert!(
716+
!stdout.contains("Bob"),
717+
"a row failing the where must be filtered out, got: {stdout:?}",
718+
);
719+
}
720+
721+
/// The same misreading, with a predicate the first row fails: the guard was
722+
/// false, so the whole block collapsed to `{}` and the caller's `count` met a
723+
/// Unit where it expected a relation ("expected Relation in len, got Unit").
724+
/// Filtering every row out must give an empty relation, not a non-relation.
725+
#[test]
726+
fn a_where_that_no_row_satisfies_yields_an_empty_relation() {
727+
let src = "type User = {name: Text, age: Int}\n\
728+
*users : [User]\n\
729+
&adults = do\n\
730+
\x20 u <- *users\n\
731+
\x20 where u.age >= 99\n\
732+
\x20 yield u\n\
733+
main = do\n\
734+
\x20 replace *users = [{name: \"Alice\", age: 30}]\n\
735+
\x20 a <- &adults\n\
736+
\x20 println (\"count: \" ++ show (count a))\n\
737+
\x20 yield {}\n";
738+
let (stdout, stderr, ok) = compile_and_run("where_matches_nothing", src, &[]);
739+
assert!(ok, "filtering every row out must not panic: {stderr}");
740+
assert!(stdout.contains("count: 0"), "the result must be empty, got: {stdout:?}");
741+
}
742+
743+
/// The other reading of `x <- *rel` — bind the WHOLE relation and pass it on as
744+
/// a value (DESIGN.md's `&seniors`) — must survive the fix. Here a `where` over
745+
/// the name *as a value* is a genuine guard on the relation, not a row filter.
746+
#[test]
747+
fn a_where_over_the_relation_as_a_value_stays_a_guard() {
748+
let src = "type Person = {name: Text, age: Int}\n\
749+
*people : [Person]\n\
750+
&seniorsIfCrowd = do\n\
751+
\x20 people <- *people\n\
752+
\x20 where count people > 1\n\
753+
\x20 yield (filter (\\p -> p.age > 65) people)\n\
754+
main = do\n\
755+
\x20 replace *people = [{name: \"Alice\", age: 70}, {name: \"Bob\", age: 20}]\n\
756+
\x20 s <- &seniorsIfCrowd\n\
757+
\x20 forEach s (\\p -> println p.name)\n\
758+
\x20 yield {}\n";
759+
let (stdout, stderr, ok) = compile_and_run("where_as_relation_guard", src, &[]);
760+
assert!(ok, "the program must run: {stderr}");
761+
assert!(
762+
stdout.contains("Alice") && !stdout.contains("Bob"),
763+
"the whole relation must reach `filter`, which keeps only Alice, got: {stdout:?}",
764+
);
765+
}

0 commit comments

Comments
 (0)