on master help sign in
Require full marker on non-pushed-down relation reads: a bare *rel read that codegen cannot push to SQL now errors (full *rel to allow the in-memory whole-table load). SourceRef gains a full flag (parsed from full *rel read position, kept distinct from the full *rel = expr write), codegen emits the error at the in-memory full-load fallback, formatter preserves full on reads, LSP/tools updated for the struct variant. Examples + all 5 docs migrated; knot.md/DESIGN.md document the requirement
commitddfebf4802b9abd018055535f66f98969c564ae7 authoriko <mail@iko.soy> dateSat 1 Aug 2026, 07:57 (2 months ago) parentd5d9edf
33 files changed+332−249
DESIGN.md+57−55 README.md+2−2 base.md+3−3 crates/knot-compiler/src/base.rs+1−1 crates/knot-compiler/src/codegen.rs+53−40 crates/knot-compiler/src/desugar.rs+8−8 crates/knot-compiler/src/infer.rs+7−7 crates/knot-compiler/src/sql_lint.rs+7−7 crates/knot-compiler/src/stratify.rs+2−2 crates/knot-compiler/src/types.rs+1−1 crates/knot-compiler/src/unused.rs+1−1 crates/knot-lsp/src/defs.rs+1−1 crates/knot-lsp/src/incremental.rs+1−1 crates/knot-lsp/src/semantic_tokens.rs+2−2 crates/knot-lsp/src/shared.rs+3−3 crates/knot-lsp/src/utils.rs+1−1 crates/knot/src/ast.rs+5−2 crates/knot/src/format.rs+3−1 crates/knot/src/parser.rs+50−8 examples/arith_aggregate.knot+2−2 examples/constraints.knot+2−2 examples/fold_stream.knot+1−1 examples/hello.knot+1−1 examples/inline_pushdown.knot+5−5 examples/let_pushdown.knot+6−6 examples/nested.knot+2−2 examples/query_opt.knot+17−17 examples/recursive.knot+1−1 examples/routes.knot+3−3 examples/stm_row_filter.knot+2−2 examples/todo.knot+3−3 knot.md+58−37 stdlib.md+21−21
DESIGN.md+57−55
@@ -51,7 +51,7 @@ httpCodes ([{code 200 name "OK"} {code 404 name "Not Found"}])
5151
5252-- Derived: references source relations, recomputed on access (read-only)
5353&seniors = do
54− people <- *people
54+ people <- full *people
5555 yield (base.filter (\p -> p.age > 65) people)
5656```
5757
@@ -132,7 +132,7 @@ type Person = {name: Text, role: Text}
132132
133133-- All people across all teams
134134&allMembers = do
135− teams <- *teams
135+ teams <- full *teams
136136 with {result do
137137 t <- teams
138138 m <- t.members
@@ -142,7 +142,7 @@ type Person = {name: Text, role: Text}
142142
143143-- Engineers on large teams
144144&engineers = do
145− teams <- *teams
145+ teams <- full *teams
146146 with {result do
147147 t <- teams
148148 where (base.count t.members) > 10
@@ -163,7 +163,7 @@ type Person = {name: Text, role: Text}
163163
164164-- Add a member to a team
165165addMember \teamName person -> do
166− teams <- *teams
166+ teams <- full *teams
167167 *teams = do
168168 t <- teams
169169 yield (case t.name == teamName of
@@ -172,7 +172,7 @@ addMember \teamName person -> do
172172
173173-- Remove a member from all teams
174174removePerson \personName -> do
175− teams <- *teams
175+ teams <- full *teams
176176 *teams = do
177177 t <- teams
178178 yield {t | members do
@@ -195,7 +195,7 @@ type FlatMembership = {team: Text, member: Text, age: Int 1}
195195
196196-- Nest: group a flat relation into nested structure
197197&nested = do
198− memberships <- *memberships
198+ memberships <- full *memberships
199199 with {result do
200200 t <- do m <- memberships; yield m.team
201201 yield {name t members (do
@@ -207,7 +207,7 @@ type FlatMembership = {team: Text, member: Text, age: Int 1}
207207
208208-- Flatten: expand nested relation into flat rows
209209&flat = do
210− teams <- *teams
210+ teams <- full *teams
211211 with {result do
212212 t <- teams
213213 m <- t.members
@@ -227,7 +227,7 @@ type Course = {name: Text, students: [{name: Text, grades: [{subject: Text, scor
227227
228228-- Find all failing grades across all departments
229229&failing = do
230− departments <- *departments
230+ departments <- full *departments
231231 with {result do
232232 d <- departments
233233 c <- d.courses
@@ -286,8 +286,8 @@ richOnes \employees departments -> do
286286
287287-- IO do block (binds from *rel, which returns IO)
288288&richEmployees = do
289− employees <- *employees
290− departments <- *departments
289+ employees <- full *employees
290+ departments <- full *departments
291291 yield (richOnes employees departments)
292292
293293-- do with Maybe
@@ -385,7 +385,7 @@ base.inter \a b -> do
385385```knot
386386*rel : [{x: Int 1}]
387387insertRow \x -> do
388− rows <- *rel
388+ rows <- full *rel
389389 *rel = base.union rows [{x x}]
390390```
391391
@@ -394,7 +394,7 @@ insertRow \x -> do
394394```knot
395395*rel : [{x: Int 1}]
396396deleteWhere \p -> do
397− rows <- *rel
397+ rows <- full *rel
398398 *rel = base.filter (\x -> not (p x)) rows
399399```
400400
@@ -403,7 +403,7 @@ deleteWhere \p -> do
403403```knot
404404*rel : [{x: Int 1}]
405405updateWhere \p f -> do
406− rows <- *rel
406+ rows <- full *rel
407407 *rel = base.map (\x -> case p x of Bool.True {} -> f x; Bool.False {} -> x) rows
408408```
409409
@@ -447,6 +447,8 @@ Binding two sources and relating them with an equi-join predicate (`e.dept == d.
447447
448448Record fields in the `yield` need explicit names — `{name e.ename}`, not `{e.ename}` (there is no field-name shorthand). Relation references (`*rel`, `&rel`) return `IO {} value`; binding them inside a read-only comprehension is handled directly by the compiler, which reads the sources as part of the query.
449449
450+**`full` reads.** When a relation read is not pushed down to SQL, the whole relation is loaded into memory, and the read must be marked with `full` before the relation name (`rows <- full *rel`). Pushed-down reads — a translatable comprehension, or a recognized aggregate such as `base.count *rel` — need no marker; the compiler reports an error at any read it cannot push down, so a full table load is always explicit in the source. Writing `full` on a read that is actually pushed down is allowed and has no effect.
451+
450452### Pipe-Forward Composition
451453
452454Derived combinators like `filter` compose with `|>`:
@@ -454,7 +456,7 @@ Derived combinators like `filter` compose with `|>`:
454456```knot
455457*employees : [{name: Text, salary: Int 1}]
456458&highEarners = do
457− employees <- *employees
459+ employees <- full *employees
458460 yield (employees
459461 |> base.filter (\e -> e.salary > 150000)
460462 |> base.map (\e -> {name e.name salary e.salary}))
@@ -469,11 +471,11 @@ data Shape = Circle {radius: Float 1} | Rect {width: Float 1, height: Float 1}
469471*shapes : [Shape]
470472
471473&circles = do -- : IO [{radius: Float 1}]
472− shapes <- *shapes
474+ shapes <- full *shapes
473475 yield (base.match Shape.Circle shapes)
474476
475477&rects = do -- : IO [{width: Float 1, height: Float 1}]
476− shapes <- *shapes
478+ shapes <- full *shapes
477479 yield (base.match Shape.Rect shapes)
478480
479481&bigCircles = do
@@ -492,7 +494,7 @@ data Status = Blocked {dependencies: [Text]} | Open {}
492494*tickets : [{title: Text, status: Status}]
493495
494496&bigCircleAreas = do
495− shapes <- *shapes
497+ shapes <- full *shapes
496498 with {result do
497499 Shape.Circle c <- shapes
498500 where c.radius > 10.0
@@ -501,7 +503,7 @@ data Status = Blocked {dependencies: [Text]} | Open {}
501503 yield result)
502504
503505&blockedDetails = do
504− tickets <- *tickets
506+ tickets <- full *tickets
505507 with {result do
506508 t <- tickets
507509 Status.Blocked {dependencies deps} <- t.status
@@ -519,7 +521,7 @@ Operate on the whole relation with `case`:
519521data Shape = Circle {radius: Float 1} | Rect {width: Float 1, height: Float 1}
520522*shapes : [Shape]
521523scale \factor -> do
522− shapes <- *shapes
524+ shapes <- full *shapes
523525 *shapes = do
524526 s <- shapes
525527 yield (case s of
@@ -562,7 +564,7 @@ Multiple key fields group by their combination:
562564*orders : [{region: Text, status: Text, amount: Int 1}]
563565&summary = do
564566 with {result do
565− o <- *orders
567+ o <- full *orders
566568 groupBy {region o.region status o.status}
567569 yield {region o.region status o.status total (base.count o)}}
568570 (do
@@ -611,7 +613,7 @@ type Person = {name: Text, age: Int 1}
611613*people : [Person]
612614}
613615(do
614− people <- *people -- IO [Person] → binds [Person]
616+ people <- full *people -- IO [Person] → binds [Person]
615617 content <- base.readFile "input.txt" -- IO Text → binds Text
616618 base.println content -- IO {}
617619 t <- base.now -- IO (Int Ms) → binds Int Ms
@@ -624,7 +626,7 @@ The pattern for querying relations is: IO-bind to get the value, then pure compr
624626```knot
625627*employees : [{name: Text, salary: Int 1}]
626628&richEmployees = do
627− employees <- *employees -- IO bind: [Employee] from IO [Employee]
629+ employees <- full *employees -- IO bind: [Employee] from IO [Employee]
628630 with {result do -- pure comprehension on the value
629631 e <- employees
630632 where e.salary > 100000
@@ -647,12 +649,12 @@ formatName \n -> base.toUpper (base.take 1 n) ++ base.drop 1 n
647649
648650-- DB read
649651&seniors = do
650− people <- *people
652+ people <- full *people
651653 yield (base.filter (\p -> p.age > 65) people)
652654
653655-- DB write
654656birthday \name -> do
655− people <- *people
657+ people <- full *people
656658 *people = do
657659 p <- people
658660 yield (case p.name == name of Bool.True {} -> {p | age (p.age + 1)}; Bool.False {} -> p)
@@ -665,7 +667,7 @@ Effect signatures are inferred; you do not write them. A function that touches t
665667```knot
666668*people : [{name: Text, age: Int 1}]
667669birthday \name -> do
668− people <- *people
670+ people <- full *people
669671 *people = do
670672 p <- people
671673 yield (case p.name == name of Bool.True {} -> {p | age (p.age + 1)}; Bool.False {} -> p)
@@ -681,9 +683,9 @@ keyword form (not a `base.` function); the block is an `IO a`.
681683-- DB writes go in `atomic`, IO happens after commit
682684handleOrder \req -> do
683685 orderId <- atomic do
684− orders <- *orders
686+ orders <- full *orders
685687 *orders = base.union orders [{item req.body.item qty 1}]
686− newOrders <- *orders
688+ newOrders <- full *orders
687689 yield (base.count newOrders)
688690 base.println ("New order #" ++ base.show orderId)
689691 yield {orderId orderId}
@@ -703,7 +705,7 @@ The compiler enforces that `retry` is only used inside `atomic`. This enables bl
703705*status : [{ready: Int 1}]
704706-- Wait until a condition is met
705707waitForReady (atomic do
706− status <- *status
708+ status <- full *status
707709 base.when (base.count (base.filter (\s -> s.ready == 1) status) == 0) base.retry
708710 yield status)
709711```
@@ -783,7 +785,7 @@ fork : IO a -> IO {}
783785*counter : [{n: Int 1}]
784786
785787increment do
786− c <- *counter
788+ c <- full *counter
787789 *counter = [{n ((base.fold (\_ x -> x.n) 0 c) + 1)}]
788790
789791do
@@ -805,7 +807,7 @@ The combination of `fork`, `atomic`, and `retry` enables STM-style concurrent co
805807*tasks : [{id: Int 1, status: Text}]
806808
807809waitForCompletion \id -> atomic do
808− tasks <- *tasks
810+ tasks <- full *tasks
809811 with {task do
810812 t <- tasks
811813 where t.id == id
@@ -900,11 +902,11 @@ route Api where
900902 PUT {owner: Text, person: Text} /todos/{title: Text}/assign -> {ok: Bool} = AssignTodo
901903
902904add \title owner priority -> do
903− todos <- *todos
905+ todos <- full *todos
904906 *todos = base.union todos [{title title owner owner priority priority status (Status.Open {})}]
905907
906908assign \title owner person -> do
907− todos <- *todos
909+ todos <- full *todos
908910 *todos = do
909911 t <- todos
910912 yield (case t.title == title of
@@ -912,7 +914,7 @@ assign \title owner person -> do
912914 Bool.False {} -> t)
913915
914916pendingFor \user page limit -> do
915− todos <- *todos
917+ todos <- full *todos
916918 with {result (do t <- todos; where t.owner == user; yield t)} yield result
917919
918920api (serve Api where
@@ -942,7 +944,7 @@ route Api where
942944
943945api (serve Api where
944946 GetUser = \{id id} -> do
945− users <- *people
947+ users <- full *people
946948 case base.filter (\u -> u.id == id) users of
947949 [ ] -> yield (Result.Err {error {status 404 message "user not found"}})
948950 Cons u rest -> yield (Result.Ok {value u})
@@ -951,7 +953,7 @@ api (serve Api where
951953 Bool.True {} -> yield (Result.Err {error {status 400 message "name required"}})
952954 Bool.False {} -> do
953955 atomic do
954− users <- *people
956+ users <- full *people
955957 *people = base.union users [{id 0 name name email email}]
956958 yield (Result.Ok {value {name name email email}}))
957959```
@@ -992,13 +994,13 @@ route Api where
992994 GET /health -> {status: Text} = HealthCheck
993995
994996addTodo \title -> do
995− todos <- *todos
997+ todos <- full *todos
996998 *todos = base.union todos [{title title}]
997999 yield {id 1}
9981000
9991001api (serve Api where
10001002 GetTodos = \{authorization authorization} -> do
1001− todos <- *todos
1003+ todos <- full *todos
10021004 yield (Result.Ok {value {body todos headers {xTotalCount (base.count todos) xPage 1}}})
10031005 CreateTodo = \{title title authorization authorization xIdempotencyKey xIdempotencyKey} -> do
10041006 r <- addTodo title
@@ -1154,9 +1156,9 @@ route Api where
11541156api (serve Api where
11551157 CreateOrder = \{item item qty qty} -> do
11561158 orderId <- atomic do
1157− orders <- *orders
1159+ orders <- full *orders
11581160 *orders = base.union orders [{item item qty qty}]
1159− newOrders <- *orders
1161+ newOrders <- full *orders
11601162 yield (base.count newOrders)
11611163 base.println ("New order #" ++ base.show orderId)
11621164 yield (Result.Ok {value {orderId orderId}}))
@@ -1167,7 +1169,7 @@ For sub-transaction boundaries:
11671169```knot
11681170*accounts : [{name: Text, balance: Int 1}]
11691171transfer \from to amount -> do
1170− accounts <- *accounts
1172+ accounts <- full *accounts
11711173 *accounts = do
11721174 a <- accounts
11731175 yield (case a.name == from of
@@ -1191,19 +1193,19 @@ All mutation is done through the `*rel = expr` write, which makes a persistent r
11911193
11921194-- Insert: union with a singleton
11931195addPerson do
1194− people <- *people
1196+ people <- full *people
11951197 *people = base.union people [{name "Alice" age 30}]
11961198
11971199-- Update: map with a conditional
11981200birthday \name -> do
1199− people <- *people
1201+ people <- full *people
12001202 *people = do
12011203 p <- people
12021204 yield (case p.name == "Alice" of Bool.True {} -> {p | age (p.age + 1)}; Bool.False {} -> p)
12031205
12041206-- Delete: filter to keep the rest
12051207removePerson \name -> do
1206− people <- *people
1208+ people <- full *people
12071209 *people = do
12081210 p <- people
12091211 where p.name != name
@@ -1219,9 +1221,9 @@ Relations are sets. Two rows are the same row iff all their fields are equal. Se
12191221
12201222-- Adding an already-existing row changes nothing
12211223addAliceTwice do
1222− people <- *people
1224+ people <- full *people
12231225 *people = base.union people [{name "Alice" age 30}]
1224− people2 <- *people
1226+ people2 <- full *people
12251227 *people = base.union people2 [{name "Alice" age 30}] -- no change
12261228```
12271229
@@ -1246,7 +1248,7 @@ data Status = Open {} | Closed {}
12461248*todos : [{title: Text, owner: Text, priority: Priority}]
12471249
12481250&seniorStaff = do -- read-only (& prefix)
1249− employees <- *employees
1251+ employees <- full *employees
12501252 yield (base.filter (\e -> e.salary > 100000) employees)
12511253
12521254*openTodos = do -- settable (* prefix)
@@ -1296,12 +1298,12 @@ data Status = Open {} | Closed {}
12961298
12971299-- Insert through view — the compiler constrains status to Open {}
12981300addOpenTodo do
1299− openTodos <- *openTodos
1301+ openTodos <- full *openTodos
13001302 *openTodos = base.union openTodos [{title "New task" owner "Alice" priority (Priority.High {}) status (Status.Open {})}]
13011303
13021304-- Delete through view — only affects rows matching the constant
13031305removeAliceTodos do
1304− openTodos <- *openTodos
1306+ openTodos <- full *openTodos
13051307 *openTodos = do
13061308 t <- openTodos
13071309 where t.owner != "Alice"
@@ -1330,7 +1332,7 @@ Datalog-style transitive closure:
13301332*manages : [{manager: Text, report: Text}]
13311333
13321334&reportsTo : [{ancestor: Text, descendant: Text}] = do
1333− manages <- *manages
1335+ manages <- full *manages
13341336 reportsTo <- &reportsTo
13351337 yield (base.union
13361338 (do m <- manages
@@ -1629,7 +1631,7 @@ type Measurement = {distance: Float M, time: Float S}
16291631
16301632-- Units flow through queries
16311633&speeds = do
1632− measurements <- *measurements
1634+ measurements <- full *measurements
16331635 with {result do
16341636 m <- measurements
16351637 yield {speed (m.distance / m.time)}} -- Float (M/S)
@@ -1912,7 +1914,7 @@ api (serve Api where
19121914 case mkPerson name age email of
19131915 Result.Ok {value person} -> do
19141916 atomic do
1915− people <- *people
1917+ people <- full *people
19161918 *people = base.union people [person]
19171919 yield (Result.Ok {value {ok (Bool.True {}) error (Maybe.Nothing {})}})
19181920 Result.Err {error e} ->
@@ -2042,7 +2044,7 @@ route Api where
20422044formatTitle \title -> base.toUpper (base.take 1 title) ++ base.drop 1 title
20432045
20442046pendingFor \user -> do
2045− todos <- *todos
2047+ todos <- full *todos
20462048 with {result do
20472049 t <- todos
20482050 where t.owner == user
@@ -2052,11 +2054,11 @@ pendingFor \user -> do
20522054 yield result)
20532055
20542056add \title owner priority -> do
2055− todos <- *todos
2057+ todos <- full *todos
20562058 *todos = base.union todos [{title (formatTitle title) owner owner priority priority status (Status.Open {})}]
20572059
20582060assign \title owner person -> do
2059− todos <- *todos
2061+ todos <- full *todos
20602062 *todos = do
20612063 t <- todos
20622064 yield (case t.title == title && t.owner == owner of
@@ -2064,7 +2066,7 @@ assign \title owner person -> do
20642066 Bool.False {} -> t)
20652067
20662068resolve \title owner msg -> do
2067− todos <- *todos
2069+ todos <- full *todos
20682070 *todos = do
20692071 t <- todos
20702072 yield (case t.title == title && t.owner == owner of
README.md+2−2
@@ -333,7 +333,7 @@ type Todo = {title: Text, done: Int 1}
333333
334334 -- update: bind the rows to a local, then map, marking one done
335335 -- ({t | done 1} is a record update)
336− todos <- *todos
336+ todos <- full *todos
337337 *todos = (do
338338 t <- todos
339339 yield (case t.title == "write guide" of
@@ -361,7 +361,7 @@ with {
361361
362362-- recomputed on every read; rows are projected from *manages
363363&directReports = (do
364− manages <- *manages
364+ manages <- full *manages
365365 yield (do
366366 m <- manages
367367 yield {manager m.manager report m.report}))
base.md+3−3
@@ -53,7 +53,7 @@ a source.
5353```knot
5454*people : [{name: Text, age: Int 1}]
5555&seniors = (do
56− people <- *people
56+ people <- full *people
5757 yield (people |> base.filter (\p -> p.age > 65)))
5858```
5959
@@ -109,7 +109,7 @@ are preserved: summing `[Float M]` gives `Float M`.
109109```knot
110110*trips : [{distance: Float Km}]
111111&totalDist = (do
112− trips <- *trips
112+ trips <- full *trips
113113 yield (base.sum (base.map (\t -> t.distance) trips)))
114114```
115115
@@ -240,7 +240,7 @@ Reorder rows by a projected key (ascending). Pushes down to `ORDER BY`; with
240240```knot
241241*people : [{name: Text, age: Int 1}]
242242&top5 = (do
243− people <- *people
243+ people <- full *people
244244 yield (people |> base.sortBy (\p -> (0 - p.age)) |> base.take 5))
245245```
246246
crates/knot-compiler/src/base.rs+1−1
@@ -298,7 +298,7 @@ fn shift_expr_spans(e: &mut ast::Expr, offset: usize) {
298298 shift_expr_spans(&mut h.body, offset);
299299 }
300300 }
301− Lit(_) | Var(_) | Constructor(_) | SourceRef(_) | DerivedRef(_) | ImplicitRef(_) | TypeHole => {}
301+ Lit(_) | Var(_) | Constructor(_) | SourceRef { .. } | DerivedRef(_) | ImplicitRef(_) | TypeHole => {}
302302 TypeCtor { .. } | DataCtor { .. } | SourceDecl { .. } | SubsetConstraint { .. } => {}
303303 RouteDecl { .. } | RouteCompositeDecl { .. } => {}
304304 ViewDecl { body, .. } | DerivedDecl { body, .. } => shift_expr_spans(body, offset),
crates/knot-compiler/src/codegen.rs+53−40
@@ -4117,7 +4117,20 @@ impl Codegen {
41174117 self.emit_ctor_as_function_value(builder, &name)
41184118 }
41194119
4120− ast::ExprKind::SourceRef(name) => {
4120+ ast::ExprKind::SourceRef { name, full } => {
4121+ // A bare `*rel` read that reaches here was not consumed by any
4122+ // SQL-pushdown matcher — it loads the whole relation into
4123+ // memory via `knot_source_read`. Require the user to opt in
4124+ // with `full *rel`.
4125+ if !full {
4126+ return self.push_codegen_error(
4127+ builder,
4128+ expr.span,
4129+ format!(
4130+ "reading `*{name}` loads the whole relation into memory (no SQL pushdown); write `full *{name}` to allow this",
4131+ ),
4132+ );
4133+ }
41214134 // Check if this is a view reference
41224135 let view_info = self.views.get(name).cloned();
41234136 if let Some(view) = view_info {
@@ -4672,7 +4685,7 @@ impl Codegen {
46724685 && fn_name == "match"
46734686 && !env.bindings.contains_key(fn_name)
46744687 && !(self.top_fn_names.contains(fn_name) && self.user_fns.contains_key(fn_name)) {
4675− if let ast::ExprKind::SourceRef(source_name) = &lhs.node
4688+ if let ast::ExprKind::SourceRef { name: source_name, .. } = &lhs.node
46764689 && let Some(schema) = self.source_schemas.get(source_name).cloned() {
46774690 let (name_ptr, name_len) =
46784691 self.string_ptr(builder, source_name);
@@ -4821,7 +4834,7 @@ impl Codegen {
48214834
48224835 ast::ExprKind::Set { target, value } => {
48234836 // target should be a SourceRef (source or view)
4824− if let ast::ExprKind::SourceRef(name) = &target.node {
4837+ if let ast::ExprKind::SourceRef { name, .. } = &target.node {
48254838 // Check if target is a view
48264839 let view_info = self.views.get(name).cloned();
48274840 if let Some(view) = view_info {
@@ -4998,7 +5011,7 @@ impl Codegen {
49985011 }
49995012
50005013 ast::ExprKind::FullSet { target, value } => {
5001− if let ast::ExprKind::SourceRef(name) = &target.node {
5014+ if let ast::ExprKind::SourceRef { name, .. } = &target.node {
50025015 // Check if target is a view
50035016 let view_info = self.views.get(name).cloned();
50045017 if let Some(view) = view_info {
@@ -5643,7 +5656,7 @@ impl Codegen {
56435656 match &expr.node {
56445657 Set { target, value } | FullSet { target, value } => {
56455658 let target_ok = match &target.node {
5646− SourceRef(name) if !self.views.contains_key(name) => {
5659+ SourceRef { name, .. } if !self.views.contains_key(name) => {
56475660 out.push(name.clone());
56485661 true
56495662 }
@@ -5729,7 +5742,7 @@ impl Codegen {
57295742 Serve { handlers, .. } => handlers
57305743 .iter()
57315744 .all(|h| self.collect_direct_write_targets(&h.body, out)),
5732− Lit(_) | Constructor(_) | SourceRef(_) | DerivedRef(_) => true,
5745+ Lit(_) | Constructor(_) | SourceRef { .. } | DerivedRef(_) => true,
57335746 TypeCtor { .. } | DataCtor { .. } | SourceDecl { .. } | SubsetConstraint { .. } => true,
57345747 ViewDecl { body, .. } | DerivedDecl { body, .. } => self.collect_direct_write_targets(body, out),
57355748 }
@@ -5762,7 +5775,7 @@ impl Codegen {
57625775 /// Handles both `*source` (SourceRef) and bound variables from `x <- *source`.
57635776 fn resolve_source(&self, expr: &ast::Expr) -> Option<String> {
57645777 match &expr.node {
5765− ast::ExprKind::SourceRef(name) => Some(name.clone()),
5778+ ast::ExprKind::SourceRef { name, .. } => Some(name.clone()),
57665779 ast::ExprKind::Var(name) => self.source_var_binds.get(name).cloned(),
57675780 _ => None,
57685781 }
@@ -6469,7 +6482,7 @@ impl Codegen {
64696482 if let ast::ExprKind::Var(name) = &func_expr.node
64706483 && name == "fold" && args.len() == 3 && !user_shadows_special {
64716484 // fold f init *source → SELECT cols FROM table; stream
6472− if let ast::ExprKind::SourceRef(source_name) = &args[2].node
6485+ if let ast::ExprKind::SourceRef { name: source_name, .. } = &args[2].node
64736486 && !self.views.contains_key(source_name)
64746487 && let Some(schema) = self.source_schemas.get(source_name).cloned()
64756488 && !schema.starts_with('#') && !schema.contains('[') {
@@ -6547,7 +6560,7 @@ impl Codegen {
65476560 if let ast::ExprKind::Var(name) = &func_expr.node
65486561 && name == "match" && args.len() == 2 && !user_shadows_special
65496562 && let ast::ExprKind::Constructor(ctor_name) = &args[0].node {
6550− if let ast::ExprKind::SourceRef(source_name) = &args[1].node
6563+ if let ast::ExprKind::SourceRef { name: source_name, .. } = &args[1].node
65516564 && let Some(schema) = self.source_schemas.get(source_name).cloned() {
65526565 let source_name = source_name.clone();
65536566 self.emit_stm_track_read(builder, &source_name);
@@ -6939,7 +6952,7 @@ impl Codegen {
69396952 && let Some((sort_bind, sort_body)) = extract_single_param_lambda(sort_lambda, &self.fun_bodies, &self.let_bindings) {
69406953 let sort_body: &ast::Expr = &sort_body;
69416954 // Case 1: sortBy f *source → SQL ORDER BY + LIMIT
6942− if let ast::ExprKind::SourceRef(source_name) = &sort_source.node
6955+ if let ast::ExprKind::SourceRef { name: source_name, .. } = &sort_source.node
69436956 && !self.views.contains_key(source_name)
69446957 && let Some(schema) = self.source_schemas.get(source_name).cloned()
69456958 && !schema.starts_with('#') && !schema.contains('[') {
@@ -8734,7 +8747,7 @@ impl Codegen {
87348747 fn expr_contains_io(expr: &ast::Expr, builtins: &HashSet<&str>, io_fns: &HashSet<String>) -> bool {
87358748 match &expr.node {
87368749 ast::ExprKind::Var(name) => builtins.contains(name.as_str()) || io_fns.contains(name),
8737− ast::ExprKind::SourceRef(_) | ast::ExprKind::DerivedRef(_) => true,
8750+ ast::ExprKind::SourceRef { .. } | ast::ExprKind::DerivedRef(_) => true,
87388751 ast::ExprKind::Set { .. } | ast::ExprKind::FullSet { .. } => true,
87398752 ast::ExprKind::Atomic(_) => true,
87408753 ast::ExprKind::App { func, arg } => {
@@ -9057,7 +9070,7 @@ impl Codegen {
90579070 ) || self.io_functions.contains(name)
90589071 || Self::io_scopes_lookup(scopes, name)
90599072 }
9060− ast::ExprKind::SourceRef(_) | ast::ExprKind::DerivedRef(_) => true,
9073+ ast::ExprKind::SourceRef { .. } | ast::ExprKind::DerivedRef(_) => true,
90619074 ast::ExprKind::Set { .. } | ast::ExprKind::FullSet { .. } => true,
90629075 ast::ExprKind::Atomic(_) => true,
90639076 ast::ExprKind::BinOp { lhs, rhs, .. } => {
@@ -9195,7 +9208,7 @@ impl Codegen {
91959208 }
91969209 // Relation reads are the "pure DB" IO that inference lets flow
91979210 // as the relation value itself.
9198− ast::ExprKind::SourceRef(_) | ast::ExprKind::DerivedRef(_) => false,
9211+ ast::ExprKind::SourceRef { .. } | ast::ExprKind::DerivedRef(_) => false,
91999212 // Writes and atomic blocks must not run at `let` time.
92009213 ast::ExprKind::Set { .. } | ast::ExprKind::FullSet { .. } => true,
92019214 ast::ExprKind::Atomic(_) => true,
@@ -9470,7 +9483,7 @@ impl Codegen {
94709483 );
94719484 let rhs_is_io_relation_source = matches!(
94729485 &expr.node,
9473− ast::ExprKind::SourceRef(_) | ast::ExprKind::DerivedRef(_)
9486+ ast::ExprKind::SourceRef { .. } | ast::ExprKind::DerivedRef(_)
94749487 );
94759488 // A comprehension TAIL inside a sequential IO block:
94769489 //
@@ -9569,7 +9582,7 @@ impl Codegen {
95699582 // `x <- *source` records x → source so inner do-blocks
95709583 // like `do { m <- x; where ...; yield m }` can compile to SQL.
95719584 if let ast::PatKind::Var(var_name) = &pat.node
9572− && let ast::ExprKind::SourceRef(source_name) = &expr.node {
9585+ && let ast::ExprKind::SourceRef { name: source_name, .. } = &expr.node {
95739586 self.source_var_binds.insert(var_name.clone(), source_name.clone());
95749587 }
95759588 // A comprehension over relation sources (`xs <- do { r <-
@@ -10251,7 +10264,7 @@ impl Codegen {
1025110264 }
1025210265 // Inner expr must be hoistable (source, derived, var, or list).
1025310266 let hoistable = match &inner_expr.node {
10254− ast::ExprKind::SourceRef(_)
10267+ ast::ExprKind::SourceRef { .. }
1025510268 | ast::ExprKind::DerivedRef(_)
1025610269 | ast::ExprKind::List(_) => true,
1025710270 // A `Var` is only hoistable when it resolves OUTSIDE this
@@ -10390,7 +10403,7 @@ impl Codegen {
1039010403 }
1039110404 primary_row_val = Some(row);
1039210405 match &expr.node {
10393− ast::ExprKind::SourceRef(name)
10406+ ast::ExprKind::SourceRef { name, .. }
1039410407 | ast::ExprKind::DerivedRef(name) => {
1039510408 primary_source = Some(name.clone());
1039610409 primary_schema = self.source_schemas.get(name).cloned();
@@ -10433,7 +10446,7 @@ impl Codegen {
1043310446
1043410447 // ── Filter pushdown: try to push Where clauses into SQL ──
1043510448 let use_filter_pushdown = if let ast::PatKind::Var(bind_var) = &pat.node {
10436− if let ast::ExprKind::SourceRef(source_name) = &expr.node {
10449+ if let ast::ExprKind::SourceRef { name: source_name, .. } = &expr.node {
1043710450 if !self.views.contains_key(source_name)
1043810451 && self.source_schemas.contains_key(source_name)
1043910452 {
@@ -10561,7 +10574,7 @@ impl Codegen {
1056110574 let rel = if matches!(&pat.node, ast::PatKind::Constructor { .. }) {
1056210575 let is_known_relation = matches!(
1056310576 &expr.node,
10564− ast::ExprKind::SourceRef(_)
10577+ ast::ExprKind::SourceRef { .. }
1056510578 | ast::ExprKind::DerivedRef(_)
1056610579 | ast::ExprKind::List(_)
1056710580 | ast::ExprKind::Do(_)
@@ -10613,7 +10626,7 @@ impl Codegen {
1061310626 }
1061410627 primary_row_val = Some(row);
1061510628 match &expr.node {
10616− ast::ExprKind::SourceRef(name)
10629+ ast::ExprKind::SourceRef { name, .. }
1061710630 | ast::ExprKind::DerivedRef(name) => {
1061810631 primary_source = Some(name.clone());
1061910632 primary_schema = self.source_schemas.get(name).cloned();
@@ -11613,7 +11626,7 @@ impl Codegen {
1161311626 /// Check whether an expression references `*<source_name>` anywhere.
1161411627 fn references_source(expr: &ast::Expr, source_name: &str) -> bool {
1161511628 match &expr.node {
11616− ast::ExprKind::SourceRef(name) => name == source_name,
11629+ ast::ExprKind::SourceRef { name, .. } => name == source_name,
1161711630 ast::ExprKind::ImplicitRef(_) => false,
1161811631 ast::ExprKind::TypeHole => false,
1161911632 ast::ExprKind::Lit(_)
@@ -11877,7 +11890,7 @@ impl Codegen {
1187711890
1187811891 // Source must be a SourceRef or a variable bound from a source read
1187911892 let source_name = match &source.node {
11880− ast::ExprKind::SourceRef(name) => name.clone(),
11893+ ast::ExprKind::SourceRef { name, .. } => name.clone(),
1188111894 ast::ExprKind::Var(name) => self.source_var_binds.get(name)?.clone(),
1188211895 _ => return None,
1188311896 };
@@ -12262,7 +12275,7 @@ impl Codegen {
1226212275 } else {
1226312276 return None;
1226412277 };
12265− let source_name = if let ast::ExprKind::SourceRef(name) = &expr.node {
12278+ let source_name = if let ast::ExprKind::SourceRef { name, .. } = &expr.node {
1226612279 name.clone()
1226712280 } else if let ast::ExprKind::Var(var_name) = &expr.node {
1226812281 // Variable bound from a source read (e.g. `allMessages <- *messages`)
@@ -13124,7 +13137,7 @@ impl Codegen {
1312413137 /// Check if an expression refers to a specific source (directly or via a bound variable).
1312513138 fn expr_is_source(node: &ast::ExprKind, source_name: &str, var_binds: &HashMap<String, String>) -> bool {
1312613139 match node {
13127− ast::ExprKind::SourceRef(name) => name == source_name,
13140+ ast::ExprKind::SourceRef { name, .. } => name == source_name,
1312813141 ast::ExprKind::Var(name) => var_binds.get(name).is_some_and(|s| s == source_name),
1312913142 _ => false,
1313013143 }
@@ -13180,7 +13193,7 @@ impl Codegen {
1318013193 if let ast::ExprKind::Do(stmts) = &value.node
1318113194 && stmts.len() == 2
1318213195 && let ast::StmtKind::Bind { expr, .. } = &stmts[0].node
13183− && let ast::ExprKind::SourceRef(name) = &expr.node
13196+ && let ast::ExprKind::SourceRef { name, .. } = &expr.node
1318413197 && name == source_name
1318513198 && let ast::StmtKind::Expr(e) = &stmts[1].node {
1318613199 return e.node.as_yield_arg().is_some();
@@ -13205,7 +13218,7 @@ impl Codegen {
1320513218
1320613219 // First: t <- *rel
1320713220 let bind_var = if let ast::StmtKind::Bind { pat, expr } = &stmts[0].node {
13208− if let ast::ExprKind::SourceRef(name) = &expr.node {
13221+ if let ast::ExprKind::SourceRef { name, .. } = &expr.node {
1320913222 if name == source_name {
1321013223 if let ast::PatKind::Var(v) = &pat.node {
1321113224 v.clone()
@@ -13787,7 +13800,7 @@ impl Codegen {
1378713800/// write rows that violate the filter), and we must not panic on valid input.
1378813801fn analyze_view(body: &ast::Expr) -> Result<Option<ViewInfo>, (ast::Span, String)> {
1378913802 // Case 1: simple alias — *view = *source
13790− if let ast::ExprKind::SourceRef(source_name) = &body.node {
13803+ if let ast::ExprKind::SourceRef { name: source_name, .. } = &body.node {
1379113804 return Ok(Some(ViewInfo {
1379213805 source_name: source_name.clone(),
1379313806 source_columns: vec![],
@@ -13801,7 +13814,7 @@ fn analyze_view(body: &ast::Expr) -> Result<Option<ViewInfo>, (ast::Span, String
1380113814 // Find the bind statement: t <- *source
1380213815 let bind_info = stmts.iter().find_map(|s| {
1380313816 if let ast::StmtKind::Bind { pat, expr } = &s.node
13804− && let ast::ExprKind::SourceRef(source_name) = &expr.node
13817+ && let ast::ExprKind::SourceRef { name: source_name, .. } = &expr.node
1380513818 && let ast::PatKind::Var(var_name) = &pat.node {
1380613819 return Some((var_name.clone(), source_name.clone()));
1380713820 }
@@ -14016,7 +14029,7 @@ fn expr_has_user_calls(expr: &ast::Expr, user_fns: &HashMap<String, (FuncId, usi
1401614029 ast::ExprKind::Lit(_)
1401714030 | ast::ExprKind::Var(_)
1401814031 | ast::ExprKind::Constructor(_)
14019− | ast::ExprKind::SourceRef(_)
14032+ | ast::ExprKind::SourceRef { .. }
1402014033 | ast::ExprKind::DerivedRef(_)
1402114034 | ast::ExprKind::List(_) => false,
1402214035 // Conservative: treat complex nodes as potentially having user calls
@@ -14029,7 +14042,7 @@ fn expr_references_var(expr: &ast::Expr, var_name: &str) -> bool {
1402914042 ast::ExprKind::Var(name) => name == var_name,
1403014043 ast::ExprKind::Lit(_)
1403114044 | ast::ExprKind::Constructor(_)
14032− | ast::ExprKind::SourceRef(_)
14045+ | ast::ExprKind::SourceRef { .. }
1403314046 | ast::ExprKind::DerivedRef(_) => false,
1403414047 ast::ExprKind::Record(fields) => fields
1403514048 .iter()
@@ -15119,7 +15132,7 @@ fn extract_filter_on_source(
1511915132 if let ast::ExprKind::App { func, arg: source_expr } = &reduced.node {
1512015133 let resolve_source = |se: &ast::Expr| -> Option<String> {
1512115134 match &se.node {
15122− ast::ExprKind::SourceRef(name) => Some(name.clone()),
15135+ ast::ExprKind::SourceRef { name, .. } => Some(name.clone()),
1512315136 ast::ExprKind::Var(name) => source_var_binds.get(name).cloned(),
1512415137 _ => None,
1512515138 }
@@ -15387,7 +15400,7 @@ fn beta_reduce_inner(
1538715400 // For constructs that bind names (Case arms, Do statements, Set, etc.)
1538815401 // we keep them unchanged: SQL pushdown never sees these inside the
1538915402 // expressions it analyzes (lambda bodies of filter/map/aggregate).
15390− Lit(_) | Constructor(_) | SourceRef(_) | DerivedRef(_) | Case { .. } | Do(_)
15403+ Lit(_) | Constructor(_) | SourceRef { .. } | DerivedRef(_) | Case { .. } | Do(_)
1539115404 | Set { .. } | FullSet { .. } | Atomic(_) | TimeUnitLit { .. }
1539215405 | Annot { .. } | Refine(_) | Serve { .. } | TypeCtor { .. } | DataCtor { .. } | SourceDecl { .. }
1539315406 | ViewDecl { .. } | DerivedDecl { .. } => return expr.clone(),
@@ -15413,7 +15426,7 @@ fn substitute_inner(
1541315426 let span = expr.span;
1541415427 let new_node = match &expr.node {
1541515428 Var(name) if name == var => return Some(value.clone()),
15416− Var(_) | Lit(_) | Constructor(_) | SourceRef(_) | DerivedRef(_) | ImplicitRef(_) | TypeHole | TypeCtor { .. }
15429+ Var(_) | Lit(_) | Constructor(_) | SourceRef { .. } | DerivedRef(_) | ImplicitRef(_) | TypeHole | TypeCtor { .. }
1541715430 | DataCtor { .. } | SourceDecl { .. } | SubsetConstraint { .. } | RouteDecl { .. } | RouteCompositeDecl { .. } => {
1541815431 return Some(expr.clone())
1541915432 }
@@ -15537,7 +15550,7 @@ fn expr_mentions_var(expr: &ast::Expr, var: &str) -> bool {
1553715550 };
1553815551 match &expr.node {
1553915552 Var(name) => name == var,
15540− Lit(_) | Constructor(_) | SourceRef(_) | DerivedRef(_) | ImplicitRef(_) | TypeHole | TypeCtor { .. }
15553+ Lit(_) | Constructor(_) | SourceRef { .. } | DerivedRef(_) | ImplicitRef(_) | TypeHole | TypeCtor { .. }
1554115554 | DataCtor { .. } | SourceDecl { .. } | SubsetConstraint { .. } | RouteDecl { .. } | RouteCompositeDecl { .. } => false,
1554215555 ViewDecl { body, .. } | DerivedDecl { body, .. } => expr_mentions_var(body, var),
1554315556 Record(fields) => fields.iter().any(|f| expr_mentions_var(&f.value, var)),
@@ -15590,7 +15603,7 @@ fn collect_free_vars_set(expr: &ast::Expr, bound: &HashSet<String>, free: &mut H
1559015603 free.insert(name.clone());
1559115604 }
1559215605 }
15593− Lit(_) | Constructor(_) | SourceRef(_) | DerivedRef(_) | ImplicitRef(_) | TypeHole | TypeCtor { .. }
15606+ Lit(_) | Constructor(_) | SourceRef { .. } | DerivedRef(_) | ImplicitRef(_) | TypeHole | TypeCtor { .. }
1559415607 | DataCtor { .. } | SourceDecl { .. } | SubsetConstraint { .. } | RouteDecl { .. } | RouteCompositeDecl { .. } => {}
1559515608 ViewDecl { body, .. } | DerivedDecl { body, .. } => collect_free_vars_set(body, bound, free),
1559615609 Lambda { params, body, .. } => {
@@ -16427,7 +16440,7 @@ fn expr_contains_derived_ref(expr: &ast::Expr, name: &str) -> bool {
1642716440 match &expr.node {
1642816441 ast::ExprKind::DerivedRef(n) => n == name,
1642916442 ast::ExprKind::Lit(_) | ast::ExprKind::Var(_) | ast::ExprKind::Constructor(_)
16430− | ast::ExprKind::SourceRef(_) | ast::ExprKind::ImplicitRef(_) | ast::ExprKind::TypeHole | ast::ExprKind::TypeCtor { .. }
16443+ | ast::ExprKind::SourceRef { .. } | ast::ExprKind::ImplicitRef(_) | ast::ExprKind::TypeHole | ast::ExprKind::TypeCtor { .. }
1643116444 | ast::ExprKind::DataCtor { .. } | ast::ExprKind::SourceDecl { .. } | ast::ExprKind::SubsetConstraint { .. } => false,
1643216445 ast::ExprKind::RouteDecl { .. } | ast::ExprKind::RouteCompositeDecl { .. } => false,
1643316446 ast::ExprKind::ViewDecl { body, .. } | ast::ExprKind::DerivedDecl { body, .. } => expr_contains_derived_ref(body, name),
@@ -16524,7 +16537,7 @@ fn collect_free_vars(expr: &ast::Expr, bound: &HashSet<&str>, free: &mut Vec<Str
1652416537 }
1652516538 }
1652616539 ast::ExprKind::Lit(_) | ast::ExprKind::Constructor(_) => {}
16527− ast::ExprKind::SourceRef(_) => {}
16540+ ast::ExprKind::SourceRef { .. } => {}
1652816541 ast::ExprKind::SourceDecl { .. } => {}
1652916542 ast::ExprKind::SubsetConstraint { .. } => {}
1653016543 ast::ExprKind::RouteDecl { .. } | ast::ExprKind::RouteCompositeDecl { .. } => {}
@@ -16698,7 +16711,7 @@ pub(crate) fn expr_refs_var(expr: &ast::Expr, var: &str) -> bool {
1669816711 ast::ExprKind::Var(name) => name == var,
1669916712 ast::ExprKind::Lit(_)
1670016713 | ast::ExprKind::Constructor(_)
16701− | ast::ExprKind::SourceRef(_)
16714+ | ast::ExprKind::SourceRef { .. }
1670216715 | ast::ExprKind::ImplicitRef(_)
1670316716 | ast::ExprKind::TypeHole
1670416717 | ast::ExprKind::DerivedRef(_) => false,
@@ -16791,7 +16804,7 @@ fn expr_uses_var_as_value(expr: &ast::Expr, var: &str) -> bool {
1679116804 ast::ExprKind::Var(name) => name == var,
1679216805 ast::ExprKind::Lit(_)
1679316806 | ast::ExprKind::Constructor(_)
16794− | ast::ExprKind::SourceRef(_)
16807+ | ast::ExprKind::SourceRef { .. }
1679516808 | ast::ExprKind::ImplicitRef(_)
1679616809 | ast::ExprKind::TypeHole
1679716810 | ast::ExprKind::DerivedRef(_) => false,
@@ -17142,7 +17155,7 @@ fn pretty_expr(expr: &ast::Expr) -> String {
1714217155 ast::ExprKind::RouteDecl { name, .. } | ast::ExprKind::RouteCompositeDecl { name, .. } => {
1714317156 format!("route {}", name)
1714417157 }
17145− ast::ExprKind::SourceRef(name) => format!("*{}", name),
17158+ ast::ExprKind::SourceRef { name, .. } => format!("*{}", name),
1714617159 ast::ExprKind::DerivedRef(name) => format!("&{}", name),
1714717160 ast::ExprKind::Record(fields) => {
1714817161 let fs: Vec<String> = fields
crates/knot-compiler/src/desugar.rs+8−8
@@ -37,7 +37,7 @@ fn desugar_inner(expr: &mut Expr) {
3737 let io_fns = detect_io_functions(expr);
3838 let no_source_vars = HashSet::new();
3939 // Rewrite `rec.*name` field access on a static source-record
40− // (`db = { *todos : [Todo], … }`) to a plain `SourceRef(name)`. The record
40+ // (`db = { *todos : [Todo], … }`) to a plain `SourceRef { name, .. }`. The record
4141 // value is erased to unit at runtime and every downstream source-read
4242 // path (do-block binds, `count`, SQL pushdown, STM tracking) keys on
4343 // `SourceRef` — routing through them unchanged is far less invasive than
@@ -198,7 +198,7 @@ enum RecordRelKind {
198198 Derived,
199199}
200200
201−/// Rewrite `rec.*name` → `SourceRef(name)` / `rec.&name` → `DerivedRef(name)`
201+/// Rewrite `rec.*name` → `SourceRef { name, .. }` / `rec.&name` → `DerivedRef(name)`
202202/// when `rec` is a static source-record that declares that relation.
203203fn rewrite_record_source_refs(expr: &mut Expr) {
204204 let map = collect_record_source_fields(expr);
@@ -225,7 +225,7 @@ fn rewrite_source_refs_in_expr(
225225 && names.iter().any(|(n, k)| n == bare && *k == kind)
226226 {
227227 expr.node = match kind {
228− RecordRelKind::Source => ExprKind::SourceRef(bare.to_string()),
228+ RecordRelKind::Source => ExprKind::SourceRef { name: bare.to_string(), full: false },
229229 RecordRelKind::Derived => ExprKind::DerivedRef(bare.to_string()),
230230 };
231231 return;
@@ -434,7 +434,7 @@ fn type_returns_io(ty: &Type) -> bool {
434434fn expr_contains_io(expr: &Expr, builtins: &HashSet<&str>, io_fns: &HashSet<String>) -> bool {
435435 match &expr.node {
436436 ExprKind::Var(name) => builtins.contains(name.as_str()) || io_fns.contains(name.as_str()),
437− ExprKind::SourceRef(_) | ExprKind::DerivedRef(_) => true,
437+ ExprKind::SourceRef { .. } | ExprKind::DerivedRef(_) => true,
438438 ExprKind::Set { .. } | ExprKind::FullSet { .. } => true,
439439 ExprKind::Atomic(_) => true,
440440 ExprKind::TimeUnitLit { value, .. } => expr_contains_io(value, builtins, io_fns),
@@ -718,7 +718,7 @@ fn desugar_expr(expr: &mut Expr, io_fns: &IoFns, source_vars: &HashSet<String>)
718718fn recurse_into_children(expr: &mut Expr, io_fns: &IoFns, source_vars: &HashSet<String>) {
719719 match &mut expr.node {
720720 ExprKind::Lit(_) | ExprKind::Var(_) | ExprKind::Constructor(_)
721− | ExprKind::SourceRef(_) | ExprKind::DerivedRef(_) | ExprKind::ImplicitRef(_) | ExprKind::TypeHole => {}
721+ | ExprKind::SourceRef { .. } | ExprKind::DerivedRef(_) | ExprKind::ImplicitRef(_) | ExprKind::TypeHole => {}
722722 ExprKind::TypeCtor { .. } | ExprKind::DataCtor { .. } | ExprKind::SourceDecl { .. } => {}
723723 ExprKind::SubsetConstraint { .. } => {}
724724 ExprKind::RouteDecl { .. } | ExprKind::RouteCompositeDecl { .. } => {}
@@ -867,7 +867,7 @@ fn desugar_do_stmts(stmts: &mut [Stmt], io_fns: &IoFns, source_vars: &HashSet<St
867867 if let StmtKind::Bind { pat, expr } = &stmt.node {
868868 let mut bound: Vec<String> = Vec::new();
869869 pat_bound_names(pat, &mut bound);
870− let is_source_read = matches!(&expr.node, ExprKind::SourceRef(_));
870+ let is_source_read = matches!(&expr.node, ExprKind::SourceRef { .. });
871871 if let (PatKind::Var(name), true) = (&pat.node, is_source_read) {
872872 local.insert(name.clone());
873873 } else {
@@ -942,7 +942,7 @@ fn is_sql_compilable(stmts: &[Stmt], source_vars: &HashSet<String>) -> bool {
942942 // provably a source-bound variable from an enclosing
943943 // do-block (codegen resolves these via source_var_binds).
944944 let ok = match &expr.node {
945− ExprKind::SourceRef(_) => true,
945+ ExprKind::SourceRef { .. } => true,
946946 ExprKind::Var(v) => source_vars.contains(v),
947947 _ => false,
948948 };
@@ -1208,7 +1208,7 @@ fn expr_is_io(expr: &Expr, io_fns: &HashSet<String>) -> bool {
12081208 && name.as_str() != "retry")
12091209 || io_fns.contains(name.as_str())
12101210 }
1211− ExprKind::SourceRef(_) | ExprKind::DerivedRef(_) => true,
1211+ ExprKind::SourceRef { .. } | ExprKind::DerivedRef(_) => true,
12121212 ExprKind::Set { .. } | ExprKind::FullSet { .. } => true,
12131213 ExprKind::Atomic(_) => true,
12141214 ExprKind::BinOp { lhs, rhs, .. } => {
crates/knot-compiler/src/infer.rs+7−7
@@ -5403,7 +5403,7 @@ impl Infer {
54035403 self.fresh()
54045404 }
54055405
5406− ast::ExprKind::SourceRef(name) => {
5406+ ast::ExprKind::SourceRef { name, .. } => {
54075407 if let Some(ty) = self.source_types.get(name).cloned() {
54085408 Ty::IO(Box::new(ty))
54095409 } else {
@@ -6416,7 +6416,7 @@ impl Infer {
64166416 self.suppress_refine_intro.replace(source_refined);
64176417 self.check_expr(value, &target_inner);
64186418 self.suppress_refine_intro = prev_suppress;
6419− if let ast::ExprKind::SourceRef(name) = &target.node {
6419+ if let ast::ExprKind::SourceRef { name, .. } = &target.node {
64206420 // `set` is a read-modify-write only when the value actually
64216421 // reads the source. Relations require that reference (it's
64226422 // enforced below), so a valid relation `set` genuinely
@@ -6468,7 +6468,7 @@ impl Infer {
64686468 self.suppress_refine_intro.replace(source_refined);
64696469 self.check_expr(value, &target_inner);
64706470 self.suppress_refine_intro = prev_suppress;
6471− if let ast::ExprKind::SourceRef(name) = &target.node {
6471+ if let ast::ExprKind::SourceRef { name, .. } = &target.node {
64726472 // Reject `full *rel = ...` when the value references
64736473 // `*rel` (directly, via a `<- *rel` bind, or via a let
64746474 // binding that ultimately reads from `*rel`) — `set`
@@ -8267,7 +8267,7 @@ impl Infer {
82678267 returns_io(&resolved)
82688268 })
82698269 }
8270− ast::ExprKind::SourceRef(_) | ast::ExprKind::DerivedRef(_) => true,
8270+ ast::ExprKind::SourceRef { .. } | ast::ExprKind::DerivedRef(_) => true,
82718271 ast::ExprKind::Set { .. } | ast::ExprKind::FullSet { .. } => true,
82728272 ast::ExprKind::Atomic(_) => true,
82738273 ast::ExprKind::BinOp { lhs, rhs, .. } => {
@@ -8404,7 +8404,7 @@ impl Infer {
84048404
84058405 // Track `x <- *foo` for `set` full-replacement detection.
84068406 if let ast::PatKind::Var(var_name) = &pat.node
8407− && let ast::ExprKind::SourceRef(source_name) = &expr.node {
8407+ && let ast::ExprKind::SourceRef { name: source_name, .. } = &expr.node {
84088408 self.source_var_binds
84098409 .insert(var_name.clone(), source_name.clone());
84108410 }
@@ -11948,7 +11948,7 @@ fn value_references_source_inner(
1194811948 visited: &mut HashSet<String>,
1194911949) -> bool {
1195011950 match &expr.node {
11951− ast::ExprKind::SourceRef(name) => name == source_name,
11951+ ast::ExprKind::SourceRef { name, .. } => name == source_name,
1195211952 // `^x` reads a record field, never a source relation directly.
1195311953 ast::ExprKind::ImplicitRef(_) => false,
1195411954 // `_` hole reads nothing.
@@ -12765,7 +12765,7 @@ fn rewrite_result_markers(expr: &mut ast::Expr, pure_spans: &HashSet<Span>) {
1276512765fn walk_expr_children_mut(expr: &mut ast::Expr, f: &mut impl FnMut(&mut ast::Expr)) {
1276612766 use ast::ExprKind::*;
1276712767 match &mut expr.node {
12768− Lit(_) | Var(_) | Constructor(_) | SourceRef(_) | DerivedRef(_) | ImplicitRef(_) => {}
12768+ Lit(_) | Var(_) | Constructor(_) | SourceRef { .. } | DerivedRef(_) | ImplicitRef(_) => {}
1276912769 TypeHole => {}
1277012770 TypeCtor { .. } | DataCtor { .. } | SourceDecl { .. } | SubsetConstraint { .. } => {}
1277112771 RouteDecl { .. } | RouteCompositeDecl { .. } => {}
crates/knot-compiler/src/sql_lint.rs+7−7
@@ -61,7 +61,7 @@ fn lint_expr(
6161) {
6262 match &expr.node {
6363 ExprKind::Set { target, value } => {
64− if let ExprKind::SourceRef(name) = &target.node {
64+ if let ExprKind::SourceRef { name, .. } = &target.node {
6565 if let Some(schema) = source_schemas.get(name) {
6666 lint_set_expr(name, schema, value, source_schemas, views, diags);
6767 }
@@ -189,7 +189,7 @@ fn lint_expr(
189189 ExprKind::Lit(_)
190190 | ExprKind::Var(_)
191191 | ExprKind::Constructor(_)
192− | ExprKind::SourceRef(_)
192+ | ExprKind::SourceRef { .. }
193193 | ExprKind::ImplicitRef(_)
194194 | ExprKind::TypeHole
195195 | ExprKind::DerivedRef(_) => {}
@@ -244,7 +244,7 @@ fn lint_do_block_skipping(
244244 _ => continue,
245245 };
246246 let source_name = match &expr.node {
247− ExprKind::SourceRef(name) => name,
247+ ExprKind::SourceRef { name, .. } => name,
248248 _ => continue,
249249 };
250250 if skip_source == Some(source_name.as_str()) {
@@ -365,7 +365,7 @@ fn lint_pipe_chain(
365365 }
366366
367367 let source_name = match &source.node {
368− ExprKind::SourceRef(name) => name,
368+ ExprKind::SourceRef { name, .. } => name,
369369 _ => return false,
370370 };
371371 if views.contains(source_name.as_str()) {
@@ -524,7 +524,7 @@ fn lint_app_form(
524524 _ => return,
525525 };
526526 let source_name = match &arg.node {
527− ExprKind::SourceRef(name) => name,
527+ ExprKind::SourceRef { name, .. } => name,
528528 _ => return,
529529 };
530530 if views.contains(source_name.as_str()) {
@@ -912,7 +912,7 @@ fn match_filter_only<'a>(
912912
913913 let bind_var = match &stmts[0].node {
914914 StmtKind::Bind { pat, expr } => match (&pat.node, &expr.node) {
915− (PatKind::Var(v), ExprKind::SourceRef(name)) if name == source_name => v.clone(),
915+ (PatKind::Var(v), ExprKind::SourceRef { name, .. }) if name == source_name => v.clone(),
916916 _ => return None,
917917 },
918918 _ => return None,
@@ -946,7 +946,7 @@ fn match_filter_only<'a>(
946946
947947fn references_source(expr: &Expr, source_name: &str) -> bool {
948948 match &expr.node {
949− ExprKind::SourceRef(name) => name == source_name,
949+ ExprKind::SourceRef { name, .. } => name == source_name,
950950 ExprKind::Lit(_)
951951 | ExprKind::Var(_)
952952 | ExprKind::Constructor(_)
crates/knot-compiler/src/stratify.rs+2−2
@@ -216,7 +216,7 @@ fn collect_edges(
216216 // (their bodies can in turn reference derived relations, forming
217217 // cycles); ordinary user sources are not nodes, so non-view
218218 // SourceRefs fall through and contribute no edge.
219− ast::ExprKind::SourceRef(name) if node_names.contains(name) => {
219+ ast::ExprKind::SourceRef { name, .. } if node_names.contains(name) => {
220220 out.push(Edge { target: name.clone(), polarity, span: expr.span });
221221 }
222222 // A variable aliasing a node carries that node's dependency at the
@@ -235,7 +235,7 @@ fn collect_edges(
235235 | ast::ExprKind::Lit(_)
236236 | ast::ExprKind::Constructor(_)
237237 | ast::ExprKind::ImplicitRef(_)
238− | ast::ExprKind::SourceRef(_)
238+ | ast::ExprKind::SourceRef { .. }
239239 | ast::ExprKind::TypeHole => {}
240240 ast::ExprKind::TypeCtor { .. } | ast::ExprKind::DataCtor { .. } | ast::ExprKind::SourceDecl { .. } | ast::ExprKind::SubsetConstraint { .. } | ast::ExprKind::RouteDecl { .. } | ast::ExprKind::RouteCompositeDecl { .. } => {}
241241 ast::ExprKind::ViewDecl { body, .. } | ast::ExprKind::DerivedDecl { body, .. } => {
crates/knot-compiler/src/types.rs+1−1
@@ -396,7 +396,7 @@ fn walk_exprs(e: &Expr, f: &mut impl FnMut(&Expr)) {
396396 ExprKind::Lit(_)
397397 | ExprKind::Var(_)
398398 | ExprKind::Constructor(_)
399− | ExprKind::SourceRef(_)
399+ | ExprKind::SourceRef { .. }
400400 | ExprKind::DerivedRef(_)
401401 | ExprKind::ImplicitRef(_)
402402 | ExprKind::TypeHole
crates/knot-compiler/src/unused.rs+1−1
@@ -256,7 +256,7 @@ fn walk_expr(e: &Expr, r: &mut Refs) {
256256 ExprKind::Constructor(name) => {
257257 r.ctors.insert(name.clone());
258258 }
259− ExprKind::SourceRef(name) => {
259+ ExprKind::SourceRef { name, .. } => {
260260 r.sources.insert(name.clone());
261261 }
262262 ExprKind::DerivedRef(name) => {
crates/knot-lsp/src/defs.rs+1−1
@@ -478,7 +478,7 @@ impl<'a> DefResolver<'a> {
478478 match &expr.node {
479479 ast::ExprKind::Var(name) => self.add_ref(expr.span, name),
480480 ast::ExprKind::Constructor(name) => self.add_ref(expr.span, name),
481− ast::ExprKind::SourceRef(name) => self.add_ref(expr.span, name),
481+ ast::ExprKind::SourceRef { name, .. } => self.add_ref(expr.span, name),
482482 ast::ExprKind::DerivedRef(name) => self.add_ref(expr.span, name),
483483 // `^name` resolves to a record field at inference time; no single
484484 // binding site to reference here.
crates/knot-lsp/src/incremental.rs+1−1
@@ -558,7 +558,7 @@ fn collect_expr_names(expr: &ast::Expr, out: &mut HashSet<String>) {
558558 ast::ExprKind::Var(name) => {
559559 out.insert(name.clone());
560560 }
561− ast::ExprKind::SourceRef(name) | ast::ExprKind::DerivedRef(name) => {
561+ ast::ExprKind::SourceRef { name, .. } | ast::ExprKind::DerivedRef(name) => {
562562 out.insert(name.clone());
563563 }
564564 ast::ExprKind::Constructor(name) => {
crates/knot-lsp/src/semantic_tokens.rs+2−2
@@ -442,7 +442,7 @@ impl<'a> TokenCollector<'a> {
442442 ast::ExprKind::Constructor(_) => {
443443 self.add(self.strip_parens(expr.span), TOK_ENUM_MEMBER, 0);
444444 }
445− ast::ExprKind::SourceRef(_) => {
445+ ast::ExprKind::SourceRef { .. } => {
446446 self.add(self.strip_parens(expr.span), TOK_NAMESPACE, 0);
447447 }
448448 ast::ExprKind::DerivedRef(_) => {
@@ -530,7 +530,7 @@ impl<'a> TokenCollector<'a> {
530530 // Highlight mutation targets distinctly. We re-emit the target
531531 // span with a MUTATION modifier overlaying whatever inner type
532532 // visit_expr would assign.
533− if let ast::ExprKind::SourceRef(_) | ast::ExprKind::DerivedRef(_) = &target.node {
533+ if let ast::ExprKind::SourceRef { .. } | ast::ExprKind::DerivedRef(_) = &target.node {
534534 self.add(target.span, TOK_NAMESPACE, MOD_MUTATION);
535535 } else {
536536 self.visit_expr(target);
crates/knot-lsp/src/shared.rs+3−3
@@ -638,7 +638,7 @@ fn render_predicate_expr(expr: &ast::Expr) -> String {
638638 ast::Literal::Bytes(_) => return None,
639639 },
640640 ast::ExprKind::Var(n) | ast::ExprKind::Constructor(n) => n.clone(),
641− ast::ExprKind::SourceRef(n) => format!("*{n}"),
641+ ast::ExprKind::SourceRef { name: n, .. } => format!("*{n}"),
642642 ast::ExprKind::DerivedRef(n) => format!("&{n}"),
643643 ast::ExprKind::FieldAccess { expr: recv, field } => {
644644 format!("{}.{field}", go(recv, true)?)
@@ -872,7 +872,7 @@ pub(crate) fn find_field_access_at_offset(
872872 fn classify_receiver(expr: &ast::Expr) -> ReceiverKind {
873873 match &expr.node {
874874 ast::ExprKind::Var(n) => ReceiverKind::Var(n.clone()),
875− ast::ExprKind::SourceRef(n) => ReceiverKind::SourceRef(n.clone()),
875+ ast::ExprKind::SourceRef { name: n, .. } => ReceiverKind::SourceRef(n.clone()),
876876 ast::ExprKind::DerivedRef(n) => ReceiverKind::DerivedRef(n.clone()),
877877 _ => ReceiverKind::Other,
878878 }
@@ -963,7 +963,7 @@ pub(crate) fn resolve_var_to_source(
963963
964964 fn rhs_source_name(rhs: &ast::Expr) -> Option<String> {
965965 match &rhs.node {
966− ast::ExprKind::SourceRef(n) | ast::ExprKind::DerivedRef(n) => Some(n.clone()),
966+ ast::ExprKind::SourceRef { name: n, .. } | ast::ExprKind::DerivedRef(n) => Some(n.clone()),
967967 // `filter f *src`, `take n *src`, etc. — peel App chains to find
968968 // an underlying source ref.
969969 ast::ExprKind::App { func, arg } => {
crates/knot-lsp/src/utils.rs+1−1
@@ -553,7 +553,7 @@ pub fn recurse_expr<F: FnMut(&ast::Expr)>(expr: &ast::Expr, mut f: F) {
553553 ast::ExprKind::Lit(_)
554554 | ast::ExprKind::Var(_)
555555 | ast::ExprKind::Constructor(_)
556− | ast::ExprKind::SourceRef(_)
556+ | ast::ExprKind::SourceRef { .. }
557557 | ast::ExprKind::DerivedRef(_)
558558 | ast::ExprKind::TypeCtor { .. }
559559 | ast::ExprKind::DataCtor { .. }
crates/knot/src/ast.rs+5−2
@@ -73,8 +73,11 @@ pub enum ExprKind {
7373 /// `Circle`, `Open` — PascalCase constructor reference.
7474 Constructor(Name),
7575
76− /// `*people` — reference to a source relation.
77− SourceRef(Name),
76+ /// `*people` — reference to a source relation. `full` is set when the
77+ /// user wrote `full *people`, acknowledging that the read loads the whole
78+ /// relation into memory (no SQL pushdown). A read that *is* pushed down
79+ /// ignores the flag.
80+ SourceRef { name: Name, full: bool },
7881
7982 /// `&seniors` — reference to a derived relation.
8083 DerivedRef(Name),
crates/knot/src/format.rs+3−1
@@ -579,7 +579,9 @@ fn render_expr_inline(e: &Expr, parent: Prec) -> String {
579579 ExprKind::Var(n) if n == "yield" && parent == Prec::Atom => format!("({})", n),
580580 ExprKind::Var(n) => n.clone(),
581581 ExprKind::Constructor(n) => n.clone(),
582− ExprKind::SourceRef(n) => format!("*{}", n),
582+ ExprKind::SourceRef { name: n, full } => {
583+ if *full { format!("full *{}", n) } else { format!("*{}", n) }
584+ }
583585 ExprKind::DerivedRef(n) => format!("&{}", n),
584586 ExprKind::ImplicitRef(n) => format!("^{}", n),
585587 ExprKind::TypeHole => "_".to_string(),
crates/knot/src/parser.rs+50−8
@@ -1379,25 +1379,67 @@ impl Parser {
13791379 TokenKind::Full => {
13801380 // `full *rel = expr` is a replace-set expression. So is
13811381 // `full db.*rel = expr` (a source-field on a record-var:
1382− // `Lower` `.` `StarIdent`). Otherwise `replace` is treated as
1383− // a regular identifier.
1382+ // `Lower` `.` `StarIdent`). A bare `full *rel` (no `=`) is a
1383+ // full-read marker: the user acknowledges the read loads the
1384+ // whole relation into memory. Otherwise `full` is a regular
1385+ // identifier.
13841386 let mut offset = 1;
13851387 while self.peek_ahead(offset) == &TokenKind::Newline {
13861388 offset += 1;
13871389 }
13881390 let next = self.peek_ahead(offset);
1389− let is_source_target = next == &TokenKind::Star || matches!(next, TokenKind::StarIdent(_));
13901391 // `db.*rel` — a record-var target whose field is a source.
13911392 let is_record_source_target = matches!(next, TokenKind::Lower(_))
13921393 && self.peek_ahead(offset + 1) == &TokenKind::Dot
13931394 && matches!(self.peek_ahead(offset + 2), TokenKind::StarIdent(_));
1394− if is_source_target || is_record_source_target {
1395+ if is_record_source_target {
13951396 let replace_start = self.span();
1396− self.advance(); // consume `replace`
1397+ self.advance(); // consume `full`
13971398 self.skip_newlines();
13981399 self.parse_set_with_start(true, replace_start)
1400+ } else if next == &TokenKind::Star || matches!(next, TokenKind::StarIdent(_)) {
1401+ // Bare `*rel` target: read vs write decided by a following `=`.
1402+ // Find the token just past the source reference.
1403+ let past = if matches!(next, TokenKind::StarIdent(_)) {
1404+ offset + 1 // `*rel` is one token
1405+ } else {
1406+ offset + 2 // `*` then `rel`
1407+ };
1408+ let after = self.peek_ahead(past);
1409+ if after == &TokenKind::Eq {
1410+ // Write: `full *rel = expr`.
1411+ let replace_start = self.span();
1412+ self.advance(); // consume `full`
1413+ self.skip_newlines();
1414+ self.parse_set_with_start(true, replace_start)
1415+ } else {
1416+ // Read: `full *rel` — mark the SourceRef as full.
1417+ let start = self.span();
1418+ self.advance(); // consume `full`
1419+ self.skip_newlines();
1420+ let tok = self.advance();
1421+ let name = match tok.kind {
1422+ TokenKind::StarIdent(n) => n.trim_start_matches('*').to_string(),
1423+ TokenKind::Star => {
1424+ let id = self.advance();
1425+ match id.kind {
1426+ TokenKind::Lower(n) => n,
1427+ _ => {
1428+ self.error("expected identifier after '*' for source reference");
1429+ return None;
1430+ }
1431+ }
1432+ }
1433+ _ => unreachable!(),
1434+ };
1435+ let end = self.prev_span();
1436+ Some(Spanned::new(
1437+ ExprKind::SourceRef { name, full: true },
1438+ Span::new(start.start, end.end),
1439+ ))
1440+ }
13991441 } else {
1400− // `replace` used as a regular identifier — fall through to
1442+ // `full` used as a regular identifier — fall through to
14011443 // Pratt parsing so binary operators and application work.
14021444 self.parse_expr_bp(0)
14031445 }
@@ -2093,7 +2135,7 @@ impl Parser {
20932135 let tok = self.advance();
20942136 let TokenKind::StarIdent(n) = tok.kind else { unreachable!() };
20952137 Some(Spanned::new(
2096− ExprKind::SourceRef(n.trim_start_matches('*').to_string()),
2138+ ExprKind::SourceRef { name: n.trim_start_matches('*').to_string(), full: false },
20972139 Span::new(start.start, tok.span.end),
20982140 ))
20992141 }
@@ -2105,7 +2147,7 @@ impl Parser {
21052147 let tok = self.advance();
21062148 let TokenKind::Lower(name) = tok.kind else { unreachable!() };
21072149 Some(Spanned::new(
2108− ExprKind::SourceRef(name),
2150+ ExprKind::SourceRef { name, full: false },
21092151 Span::new(start.start, tok.span.end),
21102152 ))
21112153 }
examples/arith_aggregate.knot+2−2
@@ -7,12 +7,12 @@ type Line = {product: Text, price: Int 1, qty: Int 1}
77*lines : [Line]
88
99totalDemo (do
10− lines <- *lines
10+ lines <- full *lines
1111 -- Revenue per line is price*qty; the whole thing is one SQL SUM.
1212 base.println ("Total revenue: " ++ base.show (*lines |> base.map (\l -> l.price * l.qty) |> base.sum)))
1313
1414avgDemo (do
15− lines <- *lines
15+ lines <- full *lines
1616 base.println ("Lines: " ++ base.show (base.count lines)))
1717}
1818
examples/constraints.knot+2−2
@@ -20,7 +20,7 @@ type Person = {name: Text, age: Int 1, email: Text}
2020
2121 -- Print current state
2222 base.println "People:"
23− base.println (base.show *people)
23+ base.println (base.show (full *people))
2424 base.println "Orders:"
25− base.println (base.show *orders)
25+ base.println (base.show (full *orders))
2626 yield {})
examples/fold_stream.knot+1−1
@@ -13,7 +13,7 @@ type Sale = {item: Text, qty: Int 1, price: Int 1}
1313 ]
1414
1515 -- Bind once so the source name resolves through source_var_binds
16− sales <- *sales
16+ sales <- full *sales
1717
1818 -- 1. fold over a bare source binding: total revenue across every row
1919 with { total (base.fold (\acc s -> acc + s.qty * s.price) 0 sales) } (do
examples/hello.knot+1−1
@@ -5,6 +5,6 @@ type Person = {name: Text, age: Int 1}
55}
66(do
77 full *people = [{name "Alice" age 30} {name "Bob" age 25} {name "Carol" age 35}]
8− p <- *people
8+ p <- full *people
99 where p.age > 27
1010 yield p.name)
examples/inline_pushdown.knot+5−5
@@ -11,27 +11,27 @@ inDept (\name e -> e.dept == name)
1111
1212-- 1. filter where the predicate body calls a helper function.
1313case1 (do
14− emps <- *employees
14+ emps <- full *employees
1515 yield (emps |> base.filter (\e -> isHighEarner e)))
1616
1717-- 2. minOn where the projection lambda body calls a helper.
1818case2 (do
19− emps <- *employees
19+ emps <- full *employees
2020 yield (emps |> base.minOn (\e -> salaryOf e)))
2121
2222-- 3. filter then count, with predicate via helper.
2323case3 (do
24− emps <- *employees
24+ emps <- full *employees
2525 with {filtered (emps |> base.filter (\e -> isHighEarner e))} yield (base.count filtered))
2626
2727-- 4. filter using a curried helper.
2828case4 (do
29− emps <- *employees
29+ emps <- full *employees
3030 yield (base.filter (inDept "Eng") emps))
3131
3232-- 5. countWhere with helper-built predicate.
3333case5 (do
34− emps <- *employees
34+ emps <- full *employees
3535 yield (base.countWhere (\e -> isHighEarner e) emps))
3636}
3737(do
examples/let_pushdown.knot+6−6
@@ -8,12 +8,12 @@ type Item = {name: Text, price: Int 1}
88
99-- Append: `union items new` hidden behind a with.
1010appendDemo (do
11− items <- *items
11+ items <- full *items
1212 with {extra [{name "Charlie" price 30}]} with {merged (base.union items extra)} *items = merged)
1313
1414-- Conditional update: do-block update body bound to a with.
1515discountDemo (do
16− items <- *items
16+ items <- full *items
1717 with {discounted (do
1818 i <- items
1919 yield (case i.price > 20 of
@@ -22,18 +22,18 @@ discountDemo (do
2222
2323-- Filter only: do-block filter body bound to a with.
2424pruneDemo (do
25− items <- *items
25+ items <- full *items
2626 with {kept (do i <- items; where i.price > 0; yield i)} *items = kept)
2727}
2828(do
2929 full *items = [{name "Alice" price 10} {name "Bob" price 25}]
3030 appendDemo
31− rows1 <- *items
31+ rows1 <- full *items
3232 base.println ("after append: " ++ base.show rows1)
3333 discountDemo
34− rows2 <- *items
34+ rows2 <- full *items
3535 base.println ("after discount: " ++ base.show rows2)
3636 pruneDemo
37− rows3 <- *items
37+ rows3 <- full *items
3838 base.println ("after prune: " ++ base.show rows3)
3939 yield {})
examples/nested.knot+2−2
@@ -13,7 +13,7 @@ type Person = {name: Text, age: Int 1}
1313
1414 -- Read nested members: bind the team, then bind its members field.
1515 base.println "Before:"
16− teams <- *teams
16+ teams <- full *teams
1717 base.forEach teams (\t -> do
1818 base.forEach t.members (\m -> base.println (" " ++ t.name ++ ": " ++ m.name)))
1919
@@ -26,7 +26,7 @@ type Person = {name: Text, age: Int 1}
2626 yield m) }
2727
2828 base.println "After:"
29− teams2 <- *teams
29+ teams2 <- full *teams
3030 base.forEach teams2 (\t -> do
3131 base.forEach t.members (\m -> base.println (" " ++ t.name ++ ": " ++ m.name)))
3232
examples/query_opt.knot+17−17
@@ -8,36 +8,36 @@ type Department = {name: Text, budget: Int 1}
88
99-- 1. Count pushdown: count *rel → SQL COUNT(*)
1010countDemo (do
11− employees <- *employees
11+ employees <- full *employees
1212 base.println ("Employee count: " ++ base.show (base.count employees)))
1313
1414-- 2. Filter pushdown: where clause → SQL WHERE
1515filterDemo (do
16− employees <- *employees
16+ employees <- full *employees
1717 base.println "High earners (salary > 75):"
1818 with {filtered (do e <- employees; where e.salary > 75; yield (" " ++ e.name ++ " ($" ++ base.show e.salary ++ ")"))} (do r <- filtered; base.println r; yield {}))
1919
2020-- 3. Hash join: equi-join via hash index
2121joinDemo (do
22− employees <- *employees
23− departments <- *departments
22+ employees <- full *employees
23+ departments <- full *departments
2424 base.println "Employee departments:"
2525 with {joined (do e <- employees; d <- departments; where e.dept == d.name; yield (" " ++ e.name ++ " works in " ++ d.name ++ " (budget: $" ++ base.show d.budget ++ ")"))} (do r <- joined; base.println r; yield {}))
2626
2727-- 4. Full SQL: single-table filter → one SQL SELECT
2828filterQuery (do
29− employees <- *employees
29+ employees <- full *employees
3030 with {result (do e <- employees; where e.salary > 75; yield {name e.name salary e.salary})} yield result)
3131
3232-- 5. Full SQL: multi-table join + filter → one SQL SELECT
3333joinQuery (do
34− employees <- *employees
35− departments <- *departments
34+ employees <- full *employees
35+ departments <- full *departments
3636 with {result (do e <- employees; d <- departments; where e.dept == d.name; where e.salary > 75; yield {name e.name dept d.name budget d.budget})} yield result)
3737
3838-- 6. Full SQL: yield entire row (single-table)
3939allEmployees (do
40− employees <- *employees
40+ employees <- full *employees
4141 with {result (do e <- employees; yield e)} yield result)
4242
4343printQuery (\label results -> do
@@ -48,45 +48,45 @@ printQuery (\label results -> do
4848
4949-- 7. Pipe: filter → SQL WHERE
5050pipeFilter (do
51− employees <- *employees
51+ employees <- full *employees
5252 yield (employees |> base.filter (\e -> e.salary > 75)))
5353
5454-- 8. Pipe: filter + map → SQL WHERE + SELECT
5555pipeFilterMap (do
56− employees <- *employees
56+ employees <- full *employees
5757 yield (employees |> base.filter (\e -> e.salary > 75) |> base.map (\e -> {name e.name salary e.salary})))
5858
5959-- 9. Pipe: filter + count
6060pipeFilterCount (do
61− employees <- *employees
61+ employees <- full *employees
6262 with {filtered (employees |> base.filter (\e -> e.salary > 75))} yield (base.count filtered))
6363
6464-- 10. Pipe: map only → SQL SELECT
6565pipeMap (do
66− employees <- *employees
66+ employees <- full *employees
6767 yield (employees |> base.map (\e -> {name e.name dept e.dept})))
6868
6969-- 11. minOn/maxOn projection → SQL MIN/MAX
7070minMaxDemo (do
71− employees <- *employees
71+ employees <- full *employees
7272 with {lo (base.minOn (\e -> e.salary) employees)} with {hi (base.maxOn (\e -> e.salary) employees)} base.println ("min salary: " ++ base.show lo ++ ", max salary: " ++ base.show hi))
7373
7474-- 12. countWhere predicate → SQL COUNT(*) WHERE
7575countWhereDemo (do
76− employees <- *employees
76+ employees <- full *employees
7777 with {n (base.countWhere (\e -> e.dept == "Eng") employees)} base.println ("eng headcount: " ++ base.show n))
7878
7979-- 13. Pipe: filter + minOn/maxOn → SQL aggregate with WHERE
8080pipeFilterMin (do
81− employees <- *employees
81+ employees <- full *employees
8282 yield (employees |> base.filter (\e -> e.dept == "Eng") |> base.minOn (\e -> e.salary)))
8383
8484pipeFilterMax (do
85− employees <- *employees
85+ employees <- full *employees
8686 yield (employees |> base.filter (\e -> e.dept == "Eng") |> base.maxOn (\e -> e.salary)))
8787
8888pipeCountWhere (do
89− employees <- *employees
89+ employees <- full *employees
9090 yield (employees |> base.countWhere (\e -> e.dept == "Sales")))
9191}
9292(do
examples/recursive.knot+1−1
@@ -4,7 +4,7 @@ with {
44*manages : [{manager: Text, report: Text}]
55
66&directReports = (do
7− manages <- *manages
7+ manages <- full *manages
88 yield (do
99 m <- manages
1010 yield {manager m.manager report m.report}))
examples/routes.knot+3−3
@@ -15,11 +15,11 @@ route AdminApi where
1515route Api = TodoApi | AdminApi
1616
1717addTodo (\title owner priority -> do
18− todos <- *todos
18+ todos <- full *todos
1919 *todos = base.union todos [{title title owner owner priority (Priority.Low {})}])
2020
2121getTodos (\owner -> do
22− todos <- *todos
22+ todos <- full *todos
2323 with {result (do t <- todos; where t.owner == owner; yield t)} yield result)
2424
2525api (serve Api where
@@ -35,7 +35,7 @@ api (serve Api where
3535 addTodo title owner priority
3636 yield (Result.Ok {value {title title owner owner priority (Priority.Low {})}})
3737 GetCount = \{} -> do
38− todos <- *todos
38+ todos <- full *todos
3939 yield (Result.Ok {value (base.count todos)}))
4040}
4141(base.listen 8080 api)
examples/stm_row_filter.knot+2−2
@@ -21,7 +21,7 @@ setBalance (\aid newBalance rows -> do
2121 Bool.False {} -> r))
2222
2323credit (\aid amount -> atomic do
24− rows <- *accounts
24+ rows <- full *accounts
2525 -- The `single (filter ...)` lookup is what the runtime SQL pushdown turns
2626 -- into a `WHERE "id" = ?` query — codegen extracts the predicate and the
2727 -- runtime calls `knot_stm_track_read_pred` to register a row-level filter.
@@ -33,6 +33,6 @@ credit (\aid amount -> atomic do
3333 credit 1 100
3434 credit 2 50
3535 credit 3 25
36− rows <- *accounts
36+ rows <- full *accounts
3737 base.println ("final: " ++ base.show rows)
3838 yield {})
examples/todo.knot+3−3
@@ -4,11 +4,11 @@ type Todo = {title: Text, owner: Text, done: Int 1}
44*todos : [Todo]
55
66add (\title owner -> do
7− todos <- *todos
7+ todos <- full *todos
88 *todos = base.union todos [{title title owner owner done 0}])
99
1010complete (\title -> do
11− todos <- *todos
11+ todos <- full *todos
1212 *todos = do
1313 t <- todos
1414 yield (case t.title == title of
@@ -16,7 +16,7 @@ complete (\title -> do
1616 Bool.False {} -> t))
1717
1818pending (\owner -> do
19− todos <- *todos
19+ todos <- full *todos
2020 with {result (do t <- todos; where t.done == 0; where t.owner == owner; yield t.title)} yield result)
2121}
2222(do
knot.md+58−37
@@ -11,7 +11,7 @@ type Person = {name: Text, age: Int 1}
1111
1212do
1313 full *people = [{name "Alice" age 30} {name "Bob" age 25}]
14− people <- *people
14+ people <- full *people
1515 with {result do
1616 p <- people
1717 where p.age > 27
@@ -156,7 +156,7 @@ data Status = Open {} | Closed {}
156156
157157-- Derived: read-only computed relation
158158&seniors = do
159− people <- *people
159+ people <- full *people
160160 yield (base.filter (\p -> p.age > 65) people)
161161
162162-- Constant
@@ -321,6 +321,27 @@ The join columns (`employees.dept`, `departments.name`) and filter columns
321321planner cannot translate falls back to reading the sources and joining in
322322memory — results are identical either way, only the strategy differs.
323323
324+### `full` reads
325+
326+When a relation read is **not** pushed down to SQL, the whole relation is
327+loaded into memory. Knot requires you to mark such a read with `full` before
328+the relation name, so the cost is explicit:
329+
330+```knot
331+*people : [{name: Text, age: Int 1}]
332+(do
333+ people <- full *people -- whole relation loaded into memory
334+ base.println (base.show (base.count people))
335+ yield {})
336+```
337+
338+A read that *is* pushed down — a comprehension the planner translates, or a
339+recognized aggregate such as `base.count *people` — needs no marker. The
340+compiler reports an error (`reading `*rel` loads the whole relation into
341+memory … write `full *rel``) at any read it cannot push down, so you always
342+know where a full table load happens. Writing `full` on a read that turns out
343+to be pushed down is allowed and has no effect.
344+
324345Statements in a `do` block:
325346
326347| Statement | Meaning |
@@ -347,7 +368,7 @@ do
347368-- IO do block with DB operations
348369*people : [{name: Text, age: Int 1}]
349370addPerson \name age -> do
350− people <- *people
371+ people <- full *people
351372 *people = base.union people [{name name age age}]
352373```
353374
@@ -363,7 +384,7 @@ data Status = InProgress {assignee: Text} | Done {}
363384*shapes : [Shape]
364385*tickets : [{title: Text, status: Status}]
365386&circles = do
366− shapes <- *shapes
387+ shapes <- full *shapes
367388 with {result do
368389 Shape.Circle c <- shapes
369390 yield c}
@@ -371,7 +392,7 @@ data Status = InProgress {assignee: Text} | Done {}
371392 yield result)
372393
373394&inProgress = do
374− tickets <- *tickets
395+ tickets <- full *tickets
375396 with {result do
376397 t <- tickets
377398 Status.InProgress ip <- t.status
@@ -391,12 +412,12 @@ Mutation is written `*rel = expr`, which makes the source relation equal to `exp
391412
392413-- Insert (union with singleton)
393414addPerson do
394− people <- *people
415+ people <- full *people
395416 *people = base.union people [{name "Alice" age 30}]
396417
397418-- Update (map with conditional)
398419birthday \name -> do
399− people <- *people
420+ people <- full *people
400421 *people = do
401422 p <- people
402423 yield (case p.name == name of
@@ -405,7 +426,7 @@ birthday \name -> do
405426
406427-- Delete (filter to keep)
407428removePerson \name -> do
408− people <- *people
429+ people <- full *people
409430 *people = do
410431 p <- people
411432 where p.name != name
@@ -559,7 +580,7 @@ Fields can hold `[T]` — sets nested inside rows:
559580
560581-- Query into nested relations
561582&allMembers = do
562− teams <- *teams
583+ teams <- full *teams
563584 with {result do
564585 t <- teams
565586 m <- t.members
@@ -569,7 +590,7 @@ Fields can hold `[T]` — sets nested inside rows:
569590
570591-- Update nested relations
571592updateTeams do
572− teams <- *teams
593+ teams <- full *teams
573594 *teams = do
574595 t <- teams
575596 yield {t | members do
@@ -609,7 +630,7 @@ data Status = Open {} | Closed {}
609630
610631-- Insert through view — status auto-filled
611632addOpenTodo do
612− openTodos <- *openTodos
633+ openTodos <- full *openTodos
613634 *openTodos = base.union openTodos [{title "New task" owner "Alice" priority Priority.High {}}]
614635```
615636
@@ -623,12 +644,12 @@ Read-only computed relations, prefixed with `&`:
623644*people : [{name: Text, age: Int 1}]
624645*todos : [{title: Text, owner: Text, done: Int 1}]
625646&seniors = do
626− people <- *people
647+ people <- full *people
627648 yield (base.filter (\p -> p.age > 65) people)
628649
629650&stats = do
630651 with {result do
631− t <- *todos
652+ t <- full *todos
632653 groupBy {owner t.owner}
633654 yield {owner t.owner total (base.count t)}}
634655 (do
@@ -644,7 +665,7 @@ Datalog-style fixpoint iteration for transitive closure:
644665
645666-- Base case: direct management edges, renamed to ancestor/descendant
646667&direct = (do
647− manages <- *manages
668+ manages <- full *manages
648669 yield (do
649670 m <- manages
650671 yield {ancestor m.manager descendant m.report}))
@@ -652,7 +673,7 @@ Datalog-style fixpoint iteration for transitive closure:
652673-- Recursive: ancestor/descendant via fixpoint over the base case
653674&reportsTo = do
654675 reportsTo <- &reportsTo -- self-reference (IO bind)
655− manages <- *manages
676+ manages <- full *manages
656677 direct <- &direct
657678 with {step do
658679 r <- reportsTo
@@ -723,7 +744,7 @@ type Person = {name: Text, age: Int 1}
723744
724745-- All relation operations are IO:
725746birthday \name -> do
726− people <- *people -- IO [Person]
747+ people <- full *people -- IO [Person]
727748 *people = do -- IO {}
728749 p <- people
729750 yield (case p.name == name of
@@ -741,9 +762,9 @@ birthday \name -> do
741762*orders : [{item: Text, qty: Int 1}]
742763handleOrder \item -> do
743764 orderId <- atomic do
744− orders <- *orders
765+ orders <- full *orders
745766 *orders = base.union orders [{item item qty 1}]
746− newOrders <- *orders
767+ newOrders <- full *orders
747768 yield (base.count newOrders)
748769 base.println ("Order #" ++ base.show orderId)
749770 yield {orderId orderId}
@@ -1016,11 +1037,11 @@ route AdminApi where
10161037route Api = TodoApi | AdminApi
10171038
10181039addTodo (\title owner -> do
1019− todos <- *todos
1040+ todos <- full *todos
10201041 *todos = base.union todos [{title title owner owner priority (Priority.Low {})}])
10211042
10221043getTodos (\owner -> do
1023− todos <- *todos
1044+ todos <- full *todos
10241045 with {result (do t <- todos; where t.owner == owner; yield t)} yield result)
10251046
10261047-- Handler
@@ -1032,7 +1053,7 @@ api (serve Api where
10321053 addTodo title owner
10331054 yield (Result.Ok {value {title title owner owner priority (Priority.Low {})}})
10341055 GetCount = \{} -> do
1035− todos <- *todos
1056+ todos <- full *todos
10361057 yield (Result.Ok {value (base.count todos)}))
10371058```
10381059
@@ -1048,7 +1069,7 @@ route Api where
10481069 GET /users/{id: Int 1} -> {id: Int 1, name: Text} = GetUser
10491070api (serve Api where
10501071 GetUser = \{id id} -> do
1051− users <- *people
1072+ users <- full *people
10521073 case base.filter (\u -> u.id == id) users of
10531074 [] -> yield (Result.Err {error {status 404 message "user not found"}})
10541075 Cons u rest -> yield (Result.Ok {value u}))
@@ -1082,7 +1103,7 @@ route Api where
10821103
10831104api (serve Api where
10841105 GetTodos = \{authorization authorization} -> do
1085− allTodos <- *todos
1106+ allTodos <- full *todos
10861107 yield (Result.Ok {value {body allTodos headers {xTotalCount (base.count allTodos)}}}))
10871108```
10881109
@@ -1316,11 +1337,11 @@ type Todo = {title: Text, owner: Text, priority: Priority, status: Status}
13161337*todos : [Todo]
13171338
13181339add \title owner priority -> do
1319− todos <- *todos
1340+ todos <- full *todos
13201341 *todos = base.union todos [{title title owner owner priority priority status (Status.Open {})}]
13211342
13221343complete \title -> do
1323− todos <- *todos
1344+ todos <- full *todos
13241345 *todos = do
13251346 t <- todos
13261347 yield (case t.title == title of
@@ -1328,7 +1349,7 @@ complete \title -> do
13281349 Bool.False {} -> t)
13291350
13301351assign \title person -> do
1331− todos <- *todos
1352+ todos <- full *todos
13321353 *todos = do
13331354 t <- todos
13341355 yield (case t.title == title of
@@ -1336,7 +1357,7 @@ assign \title person -> do
13361357 Bool.False {} -> t)
13371358
13381359pending \owner -> do
1339− todos <- *todos
1360+ todos <- full *todos
13401361 with {result do
13411362 t <- todos
13421363 where t.owner == owner
@@ -1347,7 +1368,7 @@ pending \owner -> do
13471368
13481369&workload = do
13491370 with {result do
1350− t <- *todos
1371+ t <- full *todos
13511372 where t.status == Status.Open {}
13521373 groupBy {owner t.owner}
13531374 yield {owner t.owner count (base.count t)}}
@@ -1378,7 +1399,7 @@ do
13781399```knot
13791400*rel : [{value: Int 1}]
13801401addRow \newRow -> do
1381− rel <- *rel
1402+ rel <- full *rel
13821403 *rel = base.union rel [newRow]
13831404```
13841405
@@ -1387,7 +1408,7 @@ addRow \newRow -> do
13871408```knot
13881409*rel : [{id: Int 1, field: Int 1}]
13891410deleteWhere \valueToDelete -> do
1390− rel <- *rel
1411+ rel <- full *rel
13911412 *rel = do
13921413 r <- rel
13931414 where r.field != valueToDelete
@@ -1399,7 +1420,7 @@ deleteWhere \valueToDelete -> do
13991420```knot
14001421*rel : [{id: Int 1, field: Int 1}]
14011422updateWhere \target newValue -> do
1402− rel <- *rel
1423+ rel <- full *rel
14031424 *rel = do
14041425 r <- rel
14051426 yield (case r.id == target of
@@ -1413,8 +1434,8 @@ updateWhere \target newValue -> do
14131434*employees : [{name: Text, dept: Text}]
14141435*departments : [{name: Text, budget: Int 1}]
14151436&joined = do
1416− employees <- *employees
1417− departments <- *departments
1437+ employees <- full *employees
1438+ departments <- full *departments
14181439 with {result do
14191440 e <- employees
14201441 d <- departments
@@ -1430,11 +1451,11 @@ updateWhere \target newValue -> do
14301451*orders : [{amount: Int 1}]
14311452*people : [{name: Text, age: Int 1}]
14321453getTotal do
1433− orders <- *orders
1454+ orders <- full *orders
14341455 yield (base.fold (\acc x -> acc + x.amount) 0 orders)
14351456
14361457getCount do
1437− people <- *people
1458+ people <- full *people
14381459 yield (base.count people)
14391460```
14401461
@@ -1446,12 +1467,12 @@ data Shape = Circle {radius: Float 1} | Rect {width: Float 1, height: Float 1}
14461467
14471468-- Using match
14481469&circles = do
1449− shapes <- *shapes
1470+ shapes <- full *shapes
14501471 yield (base.match Shape.Circle shapes)
14511472
14521473-- Using pattern bind in do
14531474&circles2 = do
1454− shapes <- *shapes
1475+ shapes <- full *shapes
14551476 with {result do
14561477 Shape.Circle c <- shapes
14571478 yield c}
stdlib.md+21−21
@@ -89,7 +89,7 @@ Keep rows where the predicate returns `True`.
8989```knot
9090*people : [{name: Text, age: Int 1}]
9191&seniors = (do
92− people <- *people
92+ people <- full *people
9393 yield (base.filter (\p -> p.age > 65) people))
9494```
9595
@@ -104,7 +104,7 @@ Apply a function to each row. Results are deduplicated (relations are sets).
104104```knot
105105*people : [{name: Text, age: Int 1}]
106106&names = (do
107− people <- *people
107+ people <- full *people
108108 yield (base.map (\p -> {name p.name}) people))
109109```
110110
@@ -124,10 +124,10 @@ data Shape = Circle {radius: Float 1} | Rect {width: Float 1, height: Float 1}
124124*shapes : [Shape]
125125
126126&circles = (do
127− shapes <- *shapes
127+ shapes <- full *shapes
128128 yield (base.match Shape.Circle shapes)) -- : [{radius: Float 1}]
129129&rects = (do
130− shapes <- *shapes
130+ shapes <- full *shapes
131131 yield (base.match Shape.Rect shapes)) -- : [{width: Float 1, height: Float 1}]
132132```
133133
@@ -168,7 +168,7 @@ Return the number of rows in a relation.
168168```knot
169169*people : [{name: Text, age: Int 1}]
170170&numPeople = (do
171− people <- *people
171+ people <- full *people
172172 yield (base.count people))
173173```
174174
@@ -185,7 +185,7 @@ Count rows that satisfy a predicate. Equivalent to `count . filter`, but pushes
185185```knot
186186*employees : [{name: Text, dept: Text, salary: Int 1}]
187187engHeadcount do
188− employees <- *employees
188+ employees <- full *employees
189189 yield (base.countWhere (\e -> e.dept == "Eng") employees)
190190```
191191
@@ -203,13 +203,13 @@ total (base.sum [10 20 30]) -- 60
203203-- Sum a record field by projecting first (source read is IO, so bind first):
204204*people : [{name: Text, age: Int 1}]
205205&totalAge = (do
206− people <- *people
206+ people <- full *people
207207 yield (base.sum (base.map (\p -> p.age) people)))
208208
209209-- Unit-preserving:
210210*trips : [{distance: Float M}]
211211&totalDistance = (do
212− trips <- *trips
212+ trips <- full *trips
213213 yield (base.sum (base.map (\t -> t.distance) trips))) -- Float M if distance : Float M
214214```
215215
@@ -232,11 +232,11 @@ Minimum of a projected field over a relation. The projection can return any orde
232232```knot
233233*employees : [{name: Text, salary: Int 1}]
234234lowestSalary do
235− employees <- *employees
235+ employees <- full *employees
236236 yield (base.minOn (\e -> e.salary) employees)
237237
238238firstName do
239− employees <- *employees
239+ employees <- full *employees
240240 yield (base.minOn (\e -> e.name) employees)
241241```
242242
@@ -253,7 +253,7 @@ Maximum of a projected field over a relation. Like `minOn`, works with any order
253253```knot
254254*employees : [{name: Text, salary: Int 1}]
255255highestSalary do
256− employees <- *employees
256+ employees <- full *employees
257257 yield (base.maxOn (\e -> e.salary) employees)
258258```
259259
@@ -284,8 +284,8 @@ Set union of two relations.
284284*employees : [{name: Text}]
285285*contractors : [{name: Text}]
286286&everyone = (do
287− employees <- *employees
288− contractors <- *contractors
287+ employees <- full *employees
288+ contractors <- full *contractors
289289 yield (base.union employees contractors))
290290```
291291
@@ -301,8 +301,8 @@ Set difference — rows in the first relation but not the second.
301301*employees : [{name: Text}]
302302*managers : [{name: Text}]
303303&nonManagers = (do
304− employees <- *employees
305− managers <- *managers
304+ employees <- full *employees
305+ managers <- full *managers
306306 yield (base.diff employees managers))
307307```
308308
@@ -364,7 +364,7 @@ Pushes down to SQL `ORDER BY` when applied to a source relation. Combined with `
364364```knot
365365*employees : [{name: Text, salary: Int 1}]
366366&topFive = do
367− employees <- *employees
367+ employees <- full *employees
368368 yield (employees |> base.sortBy (\e -> -e.salary) |> base.take 5)
369369-- SQL: SELECT ... FROM _knot_employees ORDER BY -salary LIMIT 5
370370```
@@ -457,7 +457,7 @@ Cancellation is cooperative but aggressive: the loser's `knot_io_run` checks its
457457```knot
458458*accounts : [{name: Text, balance: Int 1}]
459459transfer \from to amount -> atomic do
460− accounts <- *accounts
460+ accounts <- full *accounts
461461 *accounts = do
462462 a <- accounts
463463 yield (case a.name == from of
@@ -478,7 +478,7 @@ Used inside `atomic` blocks only. Causes the transaction to rollback and wait un
478478```knot
479479*tasks : [{id: Int 1, status: Text}]
480480waitForTask \id -> atomic do
481− tasks <- *tasks
481+ tasks <- full *tasks
482482 with {done do
483483 t <- tasks
484484 where t.id == id
@@ -1234,9 +1234,9 @@ double \x -> x + x
12341234
12351235(do
12361236 base.println (base.show (base.avg (\t -> t.distance) *trips))
1237− base.println (base.show (base.sum (base.map (\t -> t.distance) *trips)))
1238− base.println (base.show (base.minOn (\t -> t.distance) *trips))
1239− base.println (base.show (base.maxOn (\t -> t.distance) *trips))
1237+ base.println (base.show (base.sum (base.map (\t -> t.distance) (full *trips))))
1238+ base.println (base.show (base.minOn (\t -> t.distance) (full *trips)))
1239+ base.println (base.show (base.maxOn (\t -> t.distance) (full *trips)))
12401240 yield {})
12411241```
12421242