Skip to content

Commit 2ec07a3

Browse files
Standardize query builder syntax across Rust, TypeScript, and C# (Server/Client) (#4261)
# Description of Changes Standardizes the query builder API across all three language SDKs (Rust, TypeScript, C#) for consistency. **Rust:** - Rename `Query` struct to `RawQuery`, make `Query` a trait with `fn into_sql(self) -> String` - All builder types (`Table`, `FromWhere`, `LeftSemiJoin`, `RightSemiJoin`) implement `Query<T>` trait - Views can return `-> impl Query<T>` instead of specifying exact builder types - The `#[view]` macro auto-detects `impl Query<T>` and rewrites to `RawQuery<T>` - Add `Not` variant to `BoolExpr` with `.not()` method **TypeScript:** - Add `ne()` to `ColumnExpression` - Refactor `BooleanExpr` to `BoolExpr` class with chainable `.and()`, `.or()`, `.not()` methods - Make builders valid queries directly (`.build()` deprecated but still works) - Deprecate `from()` wrapper — use `tables.person.where(...)` directly - Merge `query` export into `tables` so table refs are also query builders - Add subscription callback form: `subscribe(ctx => ctx.from.person.where(...))` - Unify `useTable` with query builder syntax; deprecate `filter.ts` **C#:** - Add `Not()` method to `BoolExpr<TRow>` - Add `IQuery<TRow>` interface implemented by all builder types (`Table`, `FromWhere`, `LeftSemiJoin`, `RightSemiJoin`, `Query`) - Add `ToSql()` to all builder types so `.Build()` is no longer required - Update `AddQuery` to accept `IQuery<TRow>` instead of `Query<TRow>` # API and ABI breaking changes - Rust: `Query<T>` is now a trait (was a struct). The struct is renamed to `RawQuery<T>`. This is a breaking change for any code that used `Query<T>` as a type directly. - TypeScript: `BooleanExpr` is now a `BoolExpr` class (was a discriminated union type). The `query` export is deprecated in favor of `tables`. - C#: `AddQuery` now accepts `Func<QueryBuilder, IQuery<TRow>>` instead of `Func<QueryBuilder, Query<TRow>>`. Existing `.Build()` calls still work since `Query<TRow>` implements `IQuery<TRow>`. # Expected complexity level and risk 3 — Changes touch multiple language SDKs and codegen, but each individual change is straightforward. The Rust macro rewrite for `impl Query<T>` detection is the most complex piece. All existing `.build()`/`.Build()` calls continue to work. # Testing - [x] `cargo test -p spacetimedb-query-builder` — 16/16 tests pass - [x] `cargo check -p spacetimedb` — clean, no warnings - [x] `cargo check` on views-query, views-sql, views-basic, views-trapped smoketest modules — all clean - [x] `cargo test -p spacetimedb-codegen codegen_csharp` — snapshot updated, passes - [x] `npm test` (TypeScript) — 101/101 tests pass - [x] C# QueryBuilder tests — new tests for `Not()`, `IQuery<T>` interface - [ ] CI passes
1 parent ef9bee0 commit 2ec07a3

65 files changed

Lines changed: 1733 additions & 961 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/bindings-csharp/BSATN.Runtime/QueryBuilder.cs

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,12 @@ public static SqlLiteral<Uuid> Uuid(Uuid value) =>
6060
new(SqlFormat.FormatHexLiteral(value.ToString()));
6161
}
6262

63-
public readonly struct Query<TRow>
63+
public interface IQuery<TRow>
64+
{
65+
string ToSql();
66+
}
67+
68+
public readonly struct Query<TRow> : IQuery<TRow>
6469
{
6570
public string Sql { get; }
6671

@@ -87,6 +92,8 @@ public BoolExpr(string sql)
8792

8893
public BoolExpr<TRow> Or(BoolExpr<TRow> other) => new($"({Sql} OR {other.Sql})");
8994

95+
public BoolExpr<TRow> Not() => new($"(NOT {Sql})");
96+
9097
public override string ToString() => Sql;
9198
}
9299

@@ -247,7 +254,7 @@ public IxJoinEq<TRow, TOtherRow> Eq<TOtherRow>(NullableIxCol<TOtherRow, TValue>
247254
public override string ToString() => RefSql;
248255
}
249256

250-
public sealed class Table<TRow, TCols, TIxCols>
257+
public sealed class Table<TRow, TCols, TIxCols> : IQuery<TRow>
251258
{
252259
private readonly string tableName;
253260
private readonly TCols cols;
@@ -301,7 +308,7 @@ Func<TIxCols, TRightIxCols, IxJoinEq<TRow, TRightRow>> on
301308
) => new(this, right, on(ixCols, right.ixCols), leftWhereExpr: null);
302309
}
303310

304-
public sealed class FromWhere<TRow, TCols, TIxCols>
311+
public sealed class FromWhere<TRow, TCols, TIxCols> : IQuery<TRow>
305312
{
306313
private readonly Table<TRow, TCols, TIxCols> table;
307314
private readonly BoolExpr<TRow> expr;
@@ -324,7 +331,9 @@ public FromWhere<TRow, TCols, TIxCols> Filter(Func<TCols, BoolExpr<TRow>> predic
324331
public FromWhere<TRow, TCols, TIxCols> Filter(Func<TCols, TIxCols, BoolExpr<TRow>> predicate) =>
325332
Where(predicate);
326333

327-
public Query<TRow> Build() => new($"{table.ToSql()} WHERE {expr.Sql}");
334+
public string ToSql() => $"{table.ToSql()} WHERE {expr.Sql}";
335+
336+
public Query<TRow> Build() => new(ToSql());
328337

329338
public LeftSemiJoin<TRow, TCols, TIxCols, TRightRow, TRightCols, TRightIxCols> LeftSemijoin<
330339
TRightRow,
@@ -352,7 +361,7 @@ public sealed class LeftSemiJoin<
352361
TRightRow,
353362
TRightCols,
354363
TRightIxCols
355-
>
364+
> : IQuery<TLeftRow>
356365
{
357366
private readonly Table<TLeftRow, TLeftCols, TLeftIxCols> left;
358367
private readonly Table<TRightRow, TRightCols, TRightIxCols> right;
@@ -430,13 +439,13 @@ public LeftSemiJoin<
430439
TRightIxCols
431440
> Filter(Func<TLeftCols, TLeftIxCols, BoolExpr<TLeftRow>> predicate) => Where(predicate);
432441

433-
public Query<TLeftRow> Build()
442+
public string ToSql()
434443
{
435444
var whereClause = whereExpr.HasValue ? $" WHERE {whereExpr.Value.Sql}" : string.Empty;
436-
return new(
437-
$"SELECT {left.TableRefSql}.* FROM {left.TableRefSql} JOIN {right.TableRefSql} ON {leftJoinRefSql} = {rightJoinRefSql}{whereClause}"
438-
);
445+
return $"SELECT {left.TableRefSql}.* FROM {left.TableRefSql} JOIN {right.TableRefSql} ON {leftJoinRefSql} = {rightJoinRefSql}{whereClause}";
439446
}
447+
448+
public Query<TLeftRow> Build() => new(ToSql());
440449
}
441450

442451
public sealed class RightSemiJoin<
@@ -446,7 +455,7 @@ public sealed class RightSemiJoin<
446455
TRightRow,
447456
TRightCols,
448457
TRightIxCols
449-
>
458+
> : IQuery<TRightRow>
450459
{
451460
private readonly Table<TLeftRow, TLeftCols, TLeftIxCols> left;
452461
private readonly Table<TRightRow, TRightCols, TRightIxCols> right;
@@ -541,7 +550,7 @@ public RightSemiJoin<
541550
TRightIxCols
542551
> Filter(Func<TRightCols, TRightIxCols, BoolExpr<TRightRow>> predicate) => Where(predicate);
543552

544-
public Query<TRightRow> Build()
553+
public string ToSql()
545554
{
546555
var whereClause = string.Empty;
547556

@@ -558,10 +567,10 @@ public Query<TRightRow> Build()
558567
whereClause = $" WHERE {rightWhereExpr.Value.Sql}";
559568
}
560569

561-
return new(
562-
$"SELECT {right.TableRefSql}.* FROM {left.TableRefSql} JOIN {right.TableRefSql} ON {leftJoinRefSql} = {rightJoinRefSql}{whereClause}"
563-
);
570+
return $"SELECT {right.TableRefSql}.* FROM {left.TableRefSql} JOIN {right.TableRefSql} ON {leftJoinRefSql} = {rightJoinRefSql}{whereClause}";
564571
}
572+
573+
public Query<TRightRow> Build() => new(ToSql());
565574
}
566575

567576
public static class QueryBuilderExtensions

crates/bindings-macro/src/lib.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,19 @@ pub fn reducer(args: StdTokenStream, item: StdTokenStream) -> StdTokenStream {
125125

126126
#[proc_macro_attribute]
127127
pub fn view(args: StdTokenStream, item: StdTokenStream) -> StdTokenStream {
128-
cvt_attr::<ItemFn>(args, item, quote!(), |args, original_function| {
129-
let args = view::ViewArgs::parse(args, &original_function.sig.ident)?;
130-
view::view_impl(args, original_function)
131-
})
128+
let item_ts: TokenStream = item.into();
129+
let original_function = match syn::parse2::<ItemFn>(item_ts.clone()) {
130+
Ok(f) => f,
131+
Err(e) => return TokenStream::from_iter([item_ts, e.into_compile_error()]).into(),
132+
};
133+
let args = match view::ViewArgs::parse(args.into(), &original_function.sig.ident) {
134+
Ok(a) => a,
135+
Err(e) => return TokenStream::from_iter([item_ts, e.into_compile_error()]).into(),
136+
};
137+
match view::view_impl(args, &original_function) {
138+
Ok(ts) => ts.into(),
139+
Err(e) => TokenStream::from_iter([item_ts, e.into_compile_error()]).into(),
140+
}
132141
}
133142

134143
/// It turns out to be shockingly difficult to construct an [`Attribute`].

crates/bindings-macro/src/view.rs

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,26 @@ impl ViewArgs {
4646
}
4747
}
4848

49+
/// If `ty` is `impl Query<T>`, returns `Some(T)`. Otherwise `None`.
50+
fn extract_impl_query_inner(ty: &syn::Type) -> Option<&syn::Type> {
51+
if let syn::Type::ImplTrait(impl_trait) = ty {
52+
for bound in &impl_trait.bounds {
53+
if let syn::TypeParamBound::Trait(trait_bound) = bound {
54+
if let Some(seg) = trait_bound.path.segments.last() {
55+
if seg.ident == "Query" {
56+
if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
57+
if let Some(syn::GenericArgument::Type(inner)) = args.args.first() {
58+
return Some(inner);
59+
}
60+
}
61+
}
62+
}
63+
}
64+
}
65+
}
66+
None
67+
}
68+
4969
pub(crate) fn view_impl(args: ViewArgs, original_function: &ItemFn) -> syn::Result<TokenStream> {
5070
let vis = &original_function.vis;
5171
let func_name = &original_function.sig.ident;
@@ -137,7 +157,61 @@ pub(crate) fn view_impl(args: ViewArgs, original_function: &ItemFn) -> syn::Resu
137157
}
138158
};
139159

160+
let original_attrs = &original_function.attrs;
161+
let original_body = &original_function.block;
162+
163+
// Detect `impl Query<T>` return type and extract `T`.
164+
let impl_query_inner = extract_impl_query_inner(ret_ty);
165+
166+
// When the return type is `impl Query<T>`:
167+
// - Rewrite the function to return `RawQuery<T>`
168+
// - Wrap the body: `RawQuery::new(Query::into_sql({ body }))`
169+
// - Use `RawQuery<T>` for SpacetimeType/ViewReturn assertions
170+
// When the return type is `RawQuery<T>` (concrete query struct):
171+
// - Wrap with `.into()` so builder types auto-convert
172+
// Otherwise (Vec<T>, Option<T>):
173+
// - Emit unchanged to preserve type inference
174+
let (emitted_fn, effective_ret_ty) = if let Some(inner_ty) = impl_query_inner {
175+
let original_sig = &original_function.sig;
176+
// Build a new signature with the return type replaced
177+
let mut new_sig = original_sig.clone();
178+
new_sig.output = syn::parse_quote!(-> spacetimedb::RawQuery<#inner_ty>);
179+
let effective_ty: syn::Type = syn::parse_quote!(spacetimedb::RawQuery<#inner_ty>);
180+
(
181+
quote! {
182+
#(#original_attrs)*
183+
#new_sig {
184+
spacetimedb::RawQuery::new(
185+
Query::into_sql(#original_body)
186+
)
187+
}
188+
},
189+
effective_ty,
190+
)
191+
} else {
192+
let original_sig = &original_function.sig;
193+
let returns_raw_query =
194+
matches!(ret_ty, syn::Type::Path(p) if p.path.segments.last().is_some_and(|s| s.ident == "RawQuery"));
195+
let emitted_body = if returns_raw_query {
196+
quote! { { ::core::convert::Into::into(#original_body) } }
197+
} else {
198+
quote! { #original_body }
199+
};
200+
(
201+
quote! {
202+
#(#original_attrs)*
203+
#original_sig
204+
#emitted_body
205+
},
206+
ret_ty.clone(),
207+
)
208+
};
209+
210+
let eff_ret_ty = &effective_ret_ty;
211+
140212
Ok(quote! {
213+
#emitted_fn
214+
141215
const _: () = { #generated_describe_function };
142216

143217
#[allow(non_camel_case_types)]
@@ -146,7 +220,7 @@ pub(crate) fn view_impl(args: ViewArgs, original_function: &ItemFn) -> syn::Resu
146220
const _: () = {
147221
fn _assert_args #lt_params () #lt_where_clause {
148222
let _ = <#ctx_ty as spacetimedb::rt::ViewContextArg>::_ITEM;
149-
let _ = <#ret_ty as spacetimedb::rt::ViewReturn>::_ITEM;
223+
let _ = <#eff_ret_ty as spacetimedb::rt::ViewReturn>::_ITEM;
150224
}
151225
};
152226

@@ -183,7 +257,7 @@ pub(crate) fn view_impl(args: ViewArgs, original_function: &ItemFn) -> syn::Resu
183257
fn return_type(
184258
ts: &mut impl spacetimedb::sats::typespace::TypespaceBuilder
185259
) -> Option<spacetimedb::sats::AlgebraicType> {
186-
Some(<#ret_ty as spacetimedb::SpacetimeType>::make_type(ts))
260+
Some(<#eff_ret_ty as spacetimedb::SpacetimeType>::make_type(ts))
187261
}
188262
}
189263
})

crates/bindings-typescript/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"format": "prettier . --write --ignore-path ../../.prettierignore",
3030
"lint": "eslint . && prettier . --check --ignore-path ../../.prettierignore",
3131
"test": "vitest run",
32+
"test:typecheck": "vitest typecheck --run",
3233
"coverage": "vitest run --coverage",
3334
"brotli-size": "brotli-size dist/index.js",
3435
"size": "pnpm -s build && size-limit",

0 commit comments

Comments
 (0)