Skip to content

Commit eb2f7a3

Browse files
Add missing query builder docs (#4196)
# Description of Changes Document the client query builder api for rust, csharp, and typescript. Also add/fix some documentation for the module-side query builder. # API and ABI breaking changes N/A # Expected complexity level and risk 0 # Testing N/A. Just documentation. --------- Signed-off-by: joshua-spacetime <josh@clockworklabs.io> Co-authored-by: Tyler Cloutier <cloutiertyler@users.noreply.github.com>
1 parent 2ffe174 commit eb2f7a3

5 files changed

Lines changed: 142 additions & 17 deletions

File tree

docs/docs/00200-core-concepts/00100-databases/00500-cheat-sheet.md

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -586,9 +586,15 @@ export const my_player = spacetimedb.view({ name: 'my_player' }, {}, t.option(pl
586586
return ctx.db.player.identity.find(ctx.sender);
587587
});
588588
589-
// Return multiple rows
589+
// Return potentially multiple rows
590590
export const top_players = spacetimedb.view({ name: 'top_players' }, {}, t.array(player.rowType), ctx => {
591-
return ctx.db.player.iter().filter(p => p.score > 1000);
591+
return ctx.db.player.score.filter(1000);
592+
});
593+
594+
// Perform a generic filter using the query builder.
595+
// Equivalent to `SELECT * FROM player WHERE score < 1000`.
596+
export const bottom_players = spacetimedb.view({ name: 'bottom_players' }, {}, t.array(player.rowType), ctx => {
597+
return ctx.from.player.where(p => p.score.lt(1000))
592598
});
593599
```
594600

@@ -605,32 +611,45 @@ public static Player? MyPlayer(ViewContext ctx)
605611
return ctx.Db.Player.Identity.Find(ctx.Sender);
606612
}
607613

608-
// Return multiple rows
614+
// Return potentially multiple rows
609615
[SpacetimeDB.View(Public = true)]
610616
public static IEnumerable<Player> TopPlayers(ViewContext ctx)
611617
{
612-
return ctx.Db.Player.Iter().Where(p => p.Score > 1000);
618+
return ctx.Db.Player.Score.Filter(1000);
619+
}
620+
621+
// Perform a generic filter using the query builder.
622+
// Equivalent to `SELECT * FROM player WHERE score < 1000`.
623+
[SpacetimeDB.View(Public = true)]
624+
public static IQuery<Player> BottomPlayers(ViewContext ctx)
625+
{
626+
return ctx.From.Player.Where(p => p.Score.Lt(1000));
613627
}
614628
```
615629

616630
</TabItem>
617631
<TabItem value="rust" label="Rust">
618632

619633
```rust
620-
use spacetimedb::{view, ViewContext};
634+
use spacetimedb::{view, Query, ViewContext};
621635

622636
// Return single row
623637
#[view(name = my_player, public)]
624638
fn my_player(ctx: &ViewContext) -> Option<Player> {
625639
ctx.db.player().identity().find(ctx.sender())
626640
}
627641

628-
// Return multiple rows
642+
// Return potentially multiple rows
629643
#[view(name = top_players, public)]
630644
fn top_players(ctx: &ViewContext) -> Vec<Player> {
631-
ctx.db.player().iter()
632-
.filter(|p| p.score > 1000)
633-
.collect()
645+
ctx.db.player().score().filter(1000).collect()
646+
}
647+
648+
// Perform a generic filter using the query builder.
649+
// Equivalent to `SELECT * FROM player WHERE score < 1000`.
650+
#[view(name = bottom_players, public)]
651+
fn bottom_players(ctx: &ViewContext) -> impl Query<Player> {
652+
ctx.from.player().r#where(|p| p.score.lt(1000))
634653
}
635654
```
636655

docs/docs/00200-core-concepts/00200-functions/00500-views.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,8 @@ You may notice that views can only access table data through indexed lookups (`.
743743

744744
**Why SQL subscriptions can scan.** You might wonder why SQL subscription queries can include full table scans while view functions cannot. The difference is that SQL queries are not black boxes - SpacetimeDB can analyze and transform them. The query engine uses **incremental evaluation**: when rows change, it computes exactly which output rows are affected without re-running the entire query. Think of it like taking the derivative of the query - given a small change in input, compute the small change in output. Since view functions are opaque code, this kind of incremental computation isn't possible.
745745

746+
**Why query builder subscriptions can scan.** For the same reason that SQL subscriptions can scan. Anything you can do with SQL subscriptions you can do with the query builder API and vice versa.
747+
746748
**The tradeoff is acceptable for indexed access.** For point lookups (`.find()`) and small range scans (`.filter()` on indexed columns), the performance difference between full re-evaluation and incremental evaluation is small. This is why views are limited to indexed access - it's the subset of operations where the black-box limitation doesn't hurt performance.
747749

748750
If you need to aggregate or sort entire tables, consider returning a `Query` from your view instead. Since queries can be analyzed by the query engine, they support incremental evaluation even when scanning full tables. Alternatively, design your schema so the data you need is accessible through indexes.

docs/docs/00200-core-concepts/00400-subscriptions.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,9 @@ interface SubscriptionBuilder {
168168
// or later during the subscription's lifetime if the module's interface changes.
169169
onError(callback: (ctx: ErrorContext, error: Error) => void): SubscriptionBuilder;
170170

171-
// Subscribe to the following SQL queries.
171+
// Subscribe to the following SQL or typed queries.
172172
// Returns immediately; callbacks are invoked when data arrives from the server.
173-
subscribe(querySqls: string[]): SubscriptionHandle;
173+
subscribe(query_sql:string | RowTypedQuery<any, any> | Array<string | RowTypedQuery<any, any>>): SubscriptionHandle;
174174

175175
// Subscribe to all rows from all tables.
176176
// Intended for applications where memory and bandwidth are not concerns.
@@ -227,6 +227,31 @@ public sealed class SubscriptionBuilder
227227
/// in order to replicate only the subset of data which the client needs to function.
228228
/// </summary>
229229
public void SubscribeToAllTables();
230+
231+
/// <summary>
232+
/// Add a typed query to this subscription.
233+
///
234+
/// This is the entry point for building subscriptions without writing SQL by hand.
235+
/// Once a typed query is added, only typed queries may follow (SQL and typed queries cannot be mixed).
236+
/// </summary>
237+
public TypedSubscriptionBuilder AddQuery<TRow>(
238+
Func<QueryBuilder, IQuery<TRow>> build
239+
);
240+
}
241+
242+
public sealed class TypedSubscriptionBuilder
243+
{
244+
/// <summary>
245+
/// Add a typed query to this subscription.
246+
/// </summary>
247+
public TypedSubscriptionBuilder AddQuery<TRow>(
248+
Func<QueryBuilder, IQuery<TRow>> build
249+
);
250+
251+
/// <summary>
252+
/// Subscribe to all typed queries that have been added to this subscription.
253+
/// </summary>
254+
public SubscriptionHandle Subscribe();
230255
}
231256
```
232257

@@ -261,6 +286,17 @@ impl<M: SpacetimeModule> SubscriptionBuilder<M> {
261286
/// should register more precise queries via [`Self::subscribe`]
262287
/// in order to replicate only the subset of data which the client needs to function.
263288
pub fn subscribe_to_all_tables(self);
289+
290+
/// Build a query and invoke `subscribe` in order to subscribe to its results.
291+
pub fn add_query<T>(self, build: impl Fn(M::QueryBuilder) -> impl Query<T>) -> TypedSubscriptionBuilder<M>;
292+
}
293+
294+
impl<M: SpacetimeModule> TypedSubscriptionBuilder<M> {
295+
/// Build a query and invoke `subscribe` in order to subscribe to its results.
296+
pub fn add_query<T>(mut self, build: impl Fn(M::QueryBuilder) -> impl Query<T>) -> Self;
297+
298+
/// Subscribe to the queries that have been built with `add_query`.
299+
pub fn subscribe(self) -> M::SubscriptionHandle;
264300
}
265301

266302
/// Types which specify a list of query strings.

docs/docs/00200-core-concepts/00600-client-sdk-languages/00400-sdk-api.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Subscriptions replicate a subset of the database to your client, maintaining a l
2424

2525
### Creating Subscriptions
2626

27-
Subscribe to tables or queries using SQL:
27+
Subscribe to tables or queries using raw SQL:
2828

2929
<Tabs groupId="client-language" queryString>
3030
<TabItem value="typescript" label="TypeScript">
@@ -110,6 +110,63 @@ void OnSubscriptionError(const FErrorContext& Ctx)
110110
</TabItem>
111111
</Tabs>
112112
113+
Or use the query builder:
114+
115+
<Tabs groupId="client-language" queryString>
116+
<TabItem value="typescript" label="TypeScript">
117+
118+
```typescript
119+
import { queries } from './module_bindings';
120+
121+
// Subscribe with callbacks
122+
conn
123+
.subscriptionBuilder()
124+
.onApplied(ctx => {
125+
console.log(`Subscription ready with ${ctx.db.User.count()} users`);
126+
})
127+
.onError((ctx, error) => {
128+
console.error(`Subscription failed: ${error}`);
129+
})
130+
.subscribe([queries.user]);
131+
```
132+
133+
</TabItem>
134+
<TabItem value="csharp" label="C#">
135+
136+
```csharp
137+
// Subscribe with callbacks
138+
conn.SubscriptionBuilder()
139+
.OnApplied(ctx =>
140+
{
141+
Console.WriteLine($"Subscription ready with {ctx.Db.User.Count()} users");
142+
})
143+
.OnError((ctx, error) =>
144+
{
145+
Console.WriteLine($"Subscription failed: {error}");
146+
})
147+
.AddQuery(ctx => ctx.From.User())
148+
.Subscribe();
149+
```
150+
151+
</TabItem>
152+
<TabItem value="rust" label="Rust">
153+
154+
```rust
155+
// Subscribe with callbacks
156+
conn.subscription_builder()
157+
.on_applied(|ctx| {
158+
println!("Subscription ready with {} users", ctx.db().user().count());
159+
})
160+
.on_error(|ctx, error| {
161+
eprintln!("Subscription failed: {}", error);
162+
})
163+
.add_query(|ctx| ctx.from.user())
164+
.subscribe();
165+
```
166+
167+
</TabItem>
168+
</Tabs>
169+
113170
See the [Subscriptions documentation](/subscriptions) for detailed information on subscription queries and semantics. Subscribe to [tables](/tables) for row data, or to [views](/functions/views) for computed query results.
114171

115172
### Querying the Local Cache

docs/static/ai-rules/spacetimedb-typescript.mdc

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -422,11 +422,8 @@ conn.subscriptionBuilder()
422422

423423
> ⚠️ **Do NOT use Row Level Security (RLS)** — it is deprecated.
424424

425-
⚠️ **CRITICAL: Views SHOULD use index lookups, NOT `.iter()` (performance)**
426-
427-
Using `.iter()` in views is technically possible but causes severe performance issues:
428-
- Every change to ANY row in the table triggers a full re-evaluation of the view
429-
- For large tables, this becomes prohibitively expensive
425+
> ⚠️ **CRITICAL:** Procedural views (views that compute results in code) can ONLY access data via index lookups, NOT `.iter()`.
426+
> If you need a view that scans/filters across many rows (including the entire table), return a **query** built with the query builder (`ctx.from...`).
430427

431428
```typescript
432429
// Private table with index on ownerId
@@ -456,6 +453,20 @@ spacetimedb.view(
456453
);
457454
```
458455

456+
### Query builder view pattern (can scan)
457+
458+
```typescript
459+
// Query-builder views return a query; the SQL engine maintains the result incrementally.
460+
// This can scan the whole table if needed (e.g. leaderboard-style queries).
461+
spacetimedb.anonymousView(
462+
{ name: 'top_players', public: true },
463+
t.array(Player.rowType),
464+
(ctx) =>
465+
ctx.from.player
466+
.where(p => p.score.gt(1000))
467+
);
468+
```
469+
459470
### ViewContext vs AnonymousViewContext
460471
```typescript
461472
// ViewContext — has ctx.sender, result varies per user (computed per-subscriber)

0 commit comments

Comments
 (0)