Skip to content

Conversation

@rekhoff
Copy link
Contributor

@rekhoff rekhoff commented Jan 9, 2026

Description of Changes

This PR implements the C# client-side typed query builder, as assigned in #3759.

Key pieces:

  • Added a small C# runtime query-builder surface in the client SDK (sdks/csharp/src/QueryBuilder.cs):
    • Query (wraps the generated SQL string)
    • Table<TRow, TCols, TIxCols> (entry point for All() / Where(...))
    • Col<TRow, TValue> and IxCol<TRow, TValue> (typed column references)
    • BoolExpr (typed boolean expression composition)
    • SQL identifier quoting + literal formatting helpers (SqlFormat)
  • Extended C# client bindings codegen (crates/codegen/src/csharp.rs) to generate:
    • Per-table/view *Cols and *IxCols helper classes used by the typed query builder.
    • A generated per-module QueryBuilder with a From accessor for each table/view, producing Table<...> values.
    • A generated TypedSubscriptionBuilder which collects Query<TRow>.Sql values and calls the existing subscription API.
    • An AddQuery(Func<QueryBuilder, Query> build) entry point off SubscriptionBuilder, mirroring the proposal’s Rust API.
  • Fixed a codegen naming collision found during regression testing:
    • *Cols/*IxCols helpers are now named after the table/view accessor name (PascalCase) instead of the row type, since multiple tables/views can share the same row type (e.g. alias tables / views returning an existing product type).
      C# usage examples (mirroring the proposal’s Rust examples)
  1. Typed subscription flow (no raw SQL)
void Subscribe(SpacetimeDB.Types.DbConnection conn)
{
    conn.SubscriptionBuilder()
        .OnApplied(ctx => { /* ... */ })
        .OnError((ctx, err) => { /* ... */ })
        .AddQuery(qb => qb.From.Users().All())
        .AddQuery(qb => qb.From.Players().All())
        .Subscribe();
}
  1. Typed WHERE filters and boolean composition
conn.SubscriptionBuilder()
    .OnApplied(ctx => { /* ... */ })
    .OnError((ctx, err) => { /* ... */ })
    .AddQuery(qb =>
        qb.From.Players().Where(p =>
            p.Name.Eq("alice")
                .And(p.IsOnline.Eq(true))
        )
    )
    .Subscribe();
  1. “Admin can see all, otherwise only self” (proposal’s “player” view logic, but client-side)
Identity self = /* ... */;

conn.SubscriptionBuilder()
    .AddQuery(qb =>
        qb.From.Players().Where(p =>
            p.Identity.Eq(self)
        )
    )
    .Subscribe();
  1. Index-column access for query construction (IxCols)
conn.SubscriptionBuilder()
    .AddQuery(qb =>
        qb.From.Players().Where(
            qb.From.Players().IxCols.Identity.Eq(self)
        )
    )
    .Subscribe();

API and ABI breaking changes

None.

  • Additive client SDK runtime types.
  • Additive client bindings codegen output.
  • No wire-format changes.

Expected complexity level and risk

2 - Low to moderate

  • Mostly additive code + codegen.
  • The main risk is correctness/compat of generated SQL strings and name/casing conventions across languages; this is mitigated by targeted unit tests + full C# regression test runs.

Testing

  • Ran run-regression-tests.sh successfully after regenerating C# bindings.
  • Ran C# unit tests using dotnet test sdks/csharp/tests~/tests.csproj -c Release
  • Added a new unit test suite (sdks/csharp/tests~/QueryBuilderTests.cs) validating:
    • Identifier quoting / escaping
    • Literal formatting (strings, bools, enums, SpacetimeDB primitive types)
    • NULL semantics for Eq/Neq
    • Boolean expression parenthesization (And/Or/Not)

@rekhoff rekhoff self-assigned this Jan 9, 2026
@rekhoff rekhoff marked this pull request as ready for review January 9, 2026 20:54
@bfops bfops added release-1.12 backward-compatible enhancement New feature or request release-any To be landed in any release window and removed backward-compatible release-1.12 labels Jan 12, 2026
Copy link
Collaborator

@joshua-spacetime joshua-spacetime left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add some end-to-end tests?

return new BoolExpr<TRow>($"{RefSql} = {SqlFormat.FormatLiteral(value)}");
}

public BoolExpr<TRow> Neq(TValue value)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the rust builder it's ne

Comment on lines +74 to +75
public BoolExpr<TRow> IsNull() => new($"{RefSql} IS NULL");
public BoolExpr<TRow> IsNotNull() => new($"{RefSql} IS NOT NULL");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These aren't supported in SpacetimeSQL

IxCols = ixCols;
}

public Query<TRow> All() => new($"SELECT * FROM {SqlFormat.QuoteIdent(tableName)}");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think All is necessary.

{
if (value is null)
{
return "NULL";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't support NULL.

}

var t = value.GetType();
if (t.IsEnum)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also don't currently support enum comparisons

public void All_QuotesTableName()
{
var table = MakeTable("My\"Table");
Assert.Equal("SELECT * FROM \"My\"\"Table\"", table.All().Sql);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as earlier, we don't need All() here. We can just .toSql() the table ref directly.

public void Where_Eq_Null_UsesIsNull()
{
var table = MakeTable("T");
var sql = table.Where(c => c.Name.Eq(null!)).Sql;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should make Eq's arg not nullable.

public void Where_Neq_Null_UsesIsNotNull()
{
var table = MakeTable("T");
var sql = table.Where(c => c.Name.Neq(null!)).Sql;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same for all of the args

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request release-any To be landed in any release window

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants