## Description of Changes
This PR primarily affects the `bindings-macro` and `schema` crates to
review:
### Core changes
1. Replaces the `name` macro with `accessor` for **Tables, Views,
Procedures, and Reducers** in Rust modules.
2. Extends `RawModuleDefV10` with a new section for:
* case conversion policies
* explicit names
New sections are not validated in this PR so not functional.
3. Updates index behavior:
* Index names are now always **system-generated** for clients. Which
will be fixed in follow-up PR when we start validating RawModuleDef with
explicit names.
* The `accessor` name for an index is used only inside the module.
## Breaking changes (API/ABI)
1. **Rust modules**
* The `name` macro must be replaced with `accessor`.
2. **Client bindings (all languages)**
* Index names are now system-generated instead of using explicitly
provided names.
**Complexity:** 3
A follow-up PR will reintroduce explicit names with support for case
conversion.
---------
Co-authored-by: rekhoff <r.ekhoff@clockworklabs.io>
Co-authored-by: clockwork-labs-bot <clockwork-labs-bot@users.noreply.github.com>
Co-authored-by: clockwork-labs-bot <bot@clockworklabs.com>
11 KiB
Query Builder Syntax: Inconsistencies & Standardization Plan
Overview
This document catalogs inconsistencies across the query builder syntax in SpacetimeDB and proposes a standardization plan. The scope covers:
- Rust server-side (views/module query builder) —
crates/query-builder/ - TypeScript server-side (views/module query builder) —
crates/bindings-typescript/src/lib/query.ts - TypeScript client-side (subscription query builder) — same file, used via codegen'd
queryexport - TypeScript
useTablehook (React) —crates/bindings-typescript/src/lib/filter.ts - Proposals 0030 (Views) and 0031 (Client Query Builder)
Inconsistencies
1. Client entry point: query.tableName vs ctx.from.tableName
On the TypeScript server (views), tables are accessed via ctx.from.person. On the TypeScript client, they're accessed via a standalone codegen'd export called query:
// Server-side view
ctx.from.person.where(row => row.id.eq(5)).build()
// Client-side subscription (current)
import { query } from './module_bindings';
conn.subscriptionBuilder().subscribe(query.player.build());
Proposal 0031 intended client-side access to also go through ctx.from:
conn.subscriptionBuilder()
.addQuery(ctx => ctx.from.users.build())
.subscribe();
The query export should not exist. The tables export (which already exists as table definitions) should carry query builder methods directly, and the subscription API should support a callback form with ctx.from for cross-language consistency.
2. useTable uses a completely different filter system
The useTable hook uses filter.ts — a separate, simpler system with string-based column names:
// useTable (string-based, different API)
useTable(tableDef, where(eq('online', true)))
// Server-side view (typed, property-based)
ctx.from.myTable.where(row => row.online.eq(true))
filter.ts defines Expr<Column> with eq(key, value), and(...), or(...) where keys are strings. The query builder uses typed property accessors via ColumnExpression. These are completely separate codepaths with different capabilities (filter.ts only supports equality).
3. .build() is required but unnecessary
Every query must end with .build():
// TypeScript
query.player.where(row => row.online.eq(true)).build()
// Rust
ctx.from.user().r#where(|c| c.online.eq(true)).build()
In TypeScript, .build() is a no-op cast — FromBuilder and SemijoinImpl already carry the QueryBrand symbol and implement toSql(). The subscribe() method already accepts RowTypedQuery and checks via isRowTypedQuery(). The intermediate builder types already satisfy the query interface.
In Rust, .build() materializes the SQL string. But this could be deferred to when .sql() is actually called, with builder types implementing Into<Query<T>>.
4. TypeScript query builder is missing ne() (not-equal)
Rust has six comparison operators: eq, ne, gt, lt, gte, lte. TypeScript's ColumnExpression has all of these except ne.
5. Rust query builder is missing not() / NOT
TypeScript has not(expr) as a standalone function. Rust's BoolExpr only has And and Or variants — no Not.
6. Boolean combinators use different styles
Rust uses method chaining:
c.age.gt(20).and(c.age.lt(30))
TypeScript uses standalone functions:
and(row.name.eq('Alice'), row.age.eq(30))
These should be standardized on methods for consistency:
row.name.eq('Alice').and(row.age.eq(30))
7. Standalone from() wrapper is redundant
TypeScript exports a from() function that wraps a TableRef in a FromBuilder:
from(qb.person).where(row => row.name.eq('Alice')).build()
But TableRefImpl already implements From<TableDef>, so you can call .where() directly:
qb.person.where(row => row.name.eq('Alice')).build()
The from() function is redundant and should be deprecated.
8. Subscription API: addQuery chaining vs direct subscribe
Proposal 0031 (Rust) uses addQuery chaining:
ctx.subscription_builder()
.add_query(|ctx| ctx.from.users().build())
.add_query(|ctx| ctx.from.players().build())
.subscribe();
This is needed in Rust for per-query type inference and type-state transitions. In TypeScript, arrays are idiomatic and addQuery is unnecessary ceremony:
// Preferred for TypeScript — pass array to subscribe
conn.subscriptionBuilder().subscribe(ctx => [
ctx.from.user.where(r => r.online.eq(true)),
ctx.from.player,
]);
Standardization Plan
1. Remove .build() requirement
Users should not need to call .build() at the end of every query.
TypeScript: The builder types already carry the QueryBrand and implement toSql(). Update the types so From<TableDef> and SemijoinBuilder<TableDef> are assignable to Query<TableDef>. Keep .build() as a deprecated no-op for backwards compatibility.
Rust: Implementation approach TBD (e.g. Into<Query<T>>, a custom trait, or macro-level changes). The #[view] macro should accept builder types directly, not just Query<T>.
After:
// TypeScript
conn.subscriptionBuilder().subscribe(tables.user.where(r => r.online.eq(true)));
// Rust
ctx.from.user().r#where(|c| c.online.eq(true)) // returned directly from view
2. Eliminate query export, use tables with query builder methods
The codegen'd query export should be removed. The existing tables export should carry query builder capabilities (.where(), .leftSemijoin(), .rightSemijoin(), etc.) directly on each table.
3. Subscription API: callback form with ctx.from
The subscribe() method should accept a callback that receives a query context, matching cross-language consistency with Rust and C#:
// Callback form (canonical, cross-language consistent)
conn.subscriptionBuilder().subscribe(ctx => ctx.from.user.where(r => r.online.eq(true)));
// Array form
conn.subscriptionBuilder().subscribe(ctx => [
ctx.from.user.where(r => r.online.eq(true)),
ctx.from.player,
]);
// Direct expression form (also accepted, convenient shorthand)
conn.subscriptionBuilder().subscribe(tables.user.where(r => r.online.eq(true)));
The ctx mirrors whatever the Rust query context carries (currently from, potentially identity/connection info for parameterized views in the future).
No addQuery() chaining in TypeScript — pass single queries or arrays directly to subscribe().
4. Unify useTable with the query builder (React only)
Replace the string-based filter.ts system with the typed query builder:
Before:
useTable(tableDef, where(eq('online', true)))
After:
useTable(tables.user.where(row => row.online.eq(true)))
// or without a filter:
useTable(tables.user)
// with callbacks:
useTable(tables.user.where(row => row.online.eq(true)), {
onInsert: (row) => console.log('Inserted:', row),
})
This deprecates filter.ts (eq, and, or, where from that module) in favor of the query builder's typed expressions. Client-side evaluation for useTable will need to work with BooleanExpr instead of Expr.
5. Add missing ne() to TypeScript
Add ne() to ColumnExpression in query.ts, following the exact same pattern as eq, lt, gt, etc.
6. Add missing not() to Rust
Add a Not(Box<BoolExpr<T>>) variant to BoolExpr<T> in expr.rs, a .not() method on BoolExpr, and handle it in format_expr.
7. Standardize boolean combinators on method chaining
TypeScript should support method-style and/or on boolean expressions to match Rust:
// Target (method style, consistent with Rust)
row.age.gt(20).and(row.age.lt(30))
// Still supported (standalone functions)
and(row.age.gt(20), row.age.lt(30))
This means BooleanExpr in TypeScript needs .and() and .or() methods. The standalone and()/or() functions can remain as convenience.
8. Deprecate standalone from() in TypeScript
Mark from() as deprecated. All docs and examples should use the table ref directly:
// Before
from(tables.person).where(row => row.name.eq('Alice'))
// After
tables.person.where(row => row.name.eq('Alice'))
Target Syntax (All Languages)
Rust Server (Views)
#[spacetimedb::view(accessor = online_users, public)]
fn online_users(ctx: &ViewContext) -> Query<User> {
ctx.from.user().r#where(|c| c.online.eq(true))
}
#[spacetimedb::view(accessor = player_mods, public)]
fn player_mods(ctx: &AnonymousViewContext) -> Query<PlayerState> {
ctx.from
.player_state()
.left_semijoin(ctx.from.moderator(), |p, m| p.entity_id.eq(m.entity_id))
}
Rust Client (Subscriptions)
ctx.subscription_builder()
.add_query(|ctx| ctx.from.user().r#where(|c| c.online.eq(true)))
.add_query(|ctx| ctx.from.player())
.subscribe();
TypeScript Server (Views)
spacetime.anonymousView({ name: 'onlineUsers', public: true }, arrayRetValue, ctx => {
return ctx.from.user.where(row => row.online.eq(true));
});
TypeScript Client (Subscriptions)
// Callback form (cross-language consistent)
conn.subscriptionBuilder().subscribe(ctx => [
ctx.from.user.where(r => r.online.eq(true)),
ctx.from.player,
]);
// Direct form (convenient shorthand)
conn.subscriptionBuilder().subscribe(tables.user.where(r => r.online.eq(true)));
TypeScript React (useTable)
const [users, isReady] = useTable(tables.user.where(row => row.online.eq(true)));
const [allPlayers, isReady] = useTable(tables.player);
const [users, isReady] = useTable(tables.user.where(row => row.online.eq(true)), {
onInsert: (row) => console.log('New user:', row),
});
File Reference
| Component | Path |
|---|---|
| Rust query builder core | crates/query-builder/src/{lib,table,join,expr}.rs |
Rust #[table] macro codegen |
crates/bindings-macro/src/table.rs |
| Rust client SDK codegen | crates/codegen/src/rust.rs |
| Rust view context | crates/bindings/src/lib.rs |
| Rust views smoketest | crates/smoketests/modules/views-query/src/lib.rs |
| TS query builder | crates/bindings-typescript/src/lib/query.ts |
| TS filter (to deprecate) | crates/bindings-typescript/src/lib/filter.ts |
TS React useTable |
crates/bindings-typescript/src/react/useTable.ts |
| TS subscription builder | crates/bindings-typescript/src/sdk/subscription_builder_impl.ts |
| TS codegen | crates/codegen/src/typescript.rs |
| TS view type tests | crates/bindings-typescript/src/server/view.test-d.ts |
| TS query builder tests | crates/bindings-typescript/tests/query.test.ts |
| TS client query tests | crates/bindings-typescript/tests/client_query.test.ts |