Files
SpacetimeDB/sdks/csharp/tests~/QueryBuilderTests.cs
Ryan 4f0a21f948 C# client typed query builder (#3982)
# Description of Changes
This PR implements the C# client-side typed query builder, as assigned
in https://github.com/clockworklabs/SpacetimeDB/issues/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`)
  * `Join(...)` with `WhereLeft(...)` / `WhereRight(...)`
* `LeftSemijoin(...)` / `RightSemijoin(...)` with `Where(...)` chaining
* 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).
* Kept `Cols`/`IxCols` off the public surface:
* `Table.Cols` and `Table.IxCols` are internal, so consumers only access
columns via the `Where(...)`/join predicate lambdas.

C# usage examples (mirroring the proposal’s Rust examples)
1) Typed subscription flow (no raw SQL)
```csharp
void Subscribe(SpacetimeDB.Types.DbConnection conn)
{
conn.SubscriptionBuilder()
    .OnApplied(ctx => { /* ... */ })
    .OnError((ctx, err) => { /* ... */ })
    .AddQuery(qb => qb.From.Users().Build())
    .AddQuery(qb => qb.From.Players().Build())
    .Subscribe();
}
```
2) Typed `WHERE` filters and boolean composition
```csharp
conn.SubscriptionBuilder()
    .OnApplied(ctx => { /* ... */ })
    .OnError((ctx, err) => { /* ... */ })
    .AddQuery(qb => qb.From.Players().Where(p => p.Name.Eq("alice").And(p.IsOnline.Eq(true))).Build())
    .Subscribe();
```
3) “Admin can see all, otherwise only self” (proposal’s “player” view
logic, but client-side)
```csharp
Identity self = /* ... */;

conn.SubscriptionBuilder()
    .AddQuery(qb =>
        qb.From.Players().Where(p =>
            p.Identity.Eq(self)
        )
    )
    .Subscribe();
```
4) Index-column access for query construction (IxCols)
```csharp
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
- [X] Ran run-regression-tests.sh successfully after regenerating C#
bindings.
- [X] Ran C# unit tests using `dotnet test
sdks/csharp/tests~/tests.csproj -c Release`
- [X] Added a new unit test suite
(`sdks/csharp/tests~/QueryBuilderTests.cs`) validating:
  * Identifier quoting / escaping
* Literal formatting (including `Identity`/`ConnectionId`/`Uuid` hex
literals; `U128` integer literal)
  * null + enum unsupported behavior throws
  * Boolean expression parenthesization (`And`/`Or`/`Not`)
  * `Where(...)` overloads including `IxCols`-based predicates
  * left/right semijoin SQL formatting and predicate chaining
2026-01-28 02:12:59 +00:00

313 lines
9.4 KiB
C#

namespace SpacetimeDB.Tests;
using System;
using Xunit;
public sealed class QueryBuilderTests
{
private sealed class Row { }
private sealed class LeftRow { }
private sealed class RightRow { }
private sealed class RowCols
{
public Col<Row, string> Name { get; }
public Col<Row, string> Weird { get; }
public Col<Row, int> Age { get; }
public Col<Row, bool> IsAdmin { get; }
public RowCols(string tableName)
{
Name = new Col<Row, string>(tableName, "Name");
Weird = new Col<Row, string>(tableName, "we\"ird");
Age = new Col<Row, int>(tableName, "Age");
IsAdmin = new Col<Row, bool>(tableName, "IsAdmin");
}
}
private sealed class RowIxCols
{
public IxCol<Row, string> Name { get; }
public RowIxCols(string tableName)
{
Name = new IxCol<Row, string>(tableName, "Name");
}
}
private static Table<Row, RowCols, RowIxCols> MakeTable(string tableName) =>
new(tableName, new RowCols(tableName), new RowIxCols(tableName));
private sealed class RowNullableCols
{
public NullableCol<Row, string> Name { get; }
public NullableCol<Row, int> Age { get; }
public RowNullableCols(string tableName)
{
Name = new NullableCol<Row, string>(tableName, "Name");
Age = new NullableCol<Row, int>(tableName, "Age");
}
}
private sealed class RowNullableIxCols
{
public NullableIxCol<Row, string> Name { get; }
public RowNullableIxCols(string tableName)
{
Name = new NullableIxCol<Row, string>(tableName, "Name");
}
}
private static Table<Row, RowNullableCols, RowNullableIxCols> MakeNullableTable(string tableName) =>
new(tableName, new RowNullableCols(tableName), new RowNullableIxCols(tableName));
private sealed class LeftCols
{
public Col<LeftRow, int> Id { get; }
public LeftCols(string tableName)
{
Id = new Col<LeftRow, int>(tableName, "id");
}
}
private sealed class LeftIxCols
{
public IxCol<LeftRow, int> Id { get; }
public LeftIxCols(string tableName)
{
Id = new IxCol<LeftRow, int>(tableName, "id");
}
}
private sealed class RightCols
{
public Col<RightRow, int> Uid { get; }
public RightCols(string tableName)
{
Uid = new Col<RightRow, int>(tableName, "uid");
}
}
private sealed class RightIxCols
{
public IxCol<RightRow, int> Uid { get; }
public RightIxCols(string tableName)
{
Uid = new IxCol<RightRow, int>(tableName, "uid");
}
}
private static Table<LeftRow, LeftCols, LeftIxCols> MakeLeftTable(string tableName) =>
new(tableName, new LeftCols(tableName), new LeftIxCols(tableName));
private static Table<RightRow, RightCols, RightIxCols> MakeRightTable(string tableName) =>
new(tableName, new RightCols(tableName), new RightIxCols(tableName));
private sealed class LeftNullableIxCols
{
public NullableIxCol<LeftRow, int> Id { get; }
public LeftNullableIxCols(string tableName)
{
Id = new NullableIxCol<LeftRow, int>(tableName, "id");
}
}
private sealed class RightNullableIxCols
{
public NullableIxCol<RightRow, int> Uid { get; }
public RightNullableIxCols(string tableName)
{
Uid = new NullableIxCol<RightRow, int>(tableName, "uid");
}
}
private static Table<LeftRow, LeftCols, LeftNullableIxCols> MakeLeftNullableIxTable(string tableName) =>
new(tableName, new LeftCols(tableName), new LeftNullableIxCols(tableName));
private static Table<RightRow, RightCols, RightNullableIxCols> MakeRightNullableIxTable(string tableName) =>
new(tableName, new RightCols(tableName), new RightNullableIxCols(tableName));
[Fact]
public void All_QuotesTableName()
{
var table = MakeTable("My\"Table");
Assert.Equal("SELECT * FROM \"My\"\"Table\"", table.Build().Sql);
}
[Fact]
public void Where_Eq_String_EscapesSingleQuote()
{
var table = MakeTable("T");
var sql = table.Where(c => c.Name.Eq("O'Reilly")).Build().Sql;
Assert.Equal("SELECT * FROM \"T\" WHERE \"T\".\"Name\" = 'O''Reilly'", sql);
}
[Fact]
public void Where_Gt_Int_FormatsInvariant()
{
var table = MakeTable("T");
var sql = table.Where(c => c.Age.Gt(123)).Build().Sql;
Assert.Equal("SELECT * FROM \"T\" WHERE \"T\".\"Age\" > 123", sql);
}
[Fact]
public void Where_Eq_Bool_FormatsAsTrueFalse()
{
var table = MakeTable("T");
Assert.Equal(
"SELECT * FROM \"T\" WHERE \"T\".\"IsAdmin\" = TRUE",
table.Where(c => c.IsAdmin.Eq(true)).Build().Sql
);
Assert.Equal(
"SELECT * FROM \"T\" WHERE \"T\".\"IsAdmin\" = FALSE",
table.Where(c => c.IsAdmin.Eq(false)).Build().Sql
);
}
[Fact]
public void Where_WithIxColsOverload_FormatsCorrectly()
{
var table = MakeTable("T");
var sql = table.Where((_, ix) => ix.Name.Eq(SqlLit.String("x"))).Build().Sql;
Assert.Equal("SELECT * FROM \"T\" WHERE \"T\".\"Name\" = 'x'", sql);
}
[Fact]
public void Where_ChainingWhere_AddsAnd()
{
var table = MakeTable("T");
var sql = table.Where(c => c.Age.Gt(1)).Where(c => c.IsAdmin.Eq(true)).Build().Sql;
Assert.Equal(
"SELECT * FROM \"T\" WHERE (\"T\".\"Age\" > 1) AND (\"T\".\"IsAdmin\" = TRUE)",
sql
);
}
[Fact]
public void BoolExpr_AndOrNot_AddsParens()
{
var age = new Col<Row, int>("T", "Age");
var name = new Col<Row, string>("T", "Name");
var isAdmin = new Col<Row, bool>("T", "IsAdmin");
var expr = age.Gt(1).And(name.Neq("x")).Or(isAdmin.Eq(true));
Assert.Equal(
"(((\"T\".\"Age\" > 1) AND (\"T\".\"Name\" <> 'x')) OR (\"T\".\"IsAdmin\" = TRUE))",
expr.Sql
);
}
[Fact]
public void QuoteIdent_EscapesDoubleQuotesInColumnName()
{
var table = MakeTable("T");
var sql = table.Where(c => c.Weird.Eq("x")).Build().Sql;
Assert.Equal("SELECT * FROM \"T\" WHERE \"T\".\"we\"\"ird\" = 'x'", sql);
}
[Fact]
public void FormatLiteral_SpacetimeDbTypes_AreQuoted()
{
var table = MakeTable("T");
var identity = Identity.FromHexString(new string('0', 64));
Assert.Equal(
$"SELECT * FROM \"T\" WHERE \"T\".\"Name\" = 0x{identity}",
table.Where(_ => new Col<Row, Identity>("T", "Name").Eq(identity)).Build().Sql
);
var connId = ConnectionId.FromHexString(new string('0', 31) + "1") ?? throw new InvalidOperationException();
Assert.Equal(
$"SELECT * FROM \"T\" WHERE \"T\".\"Name\" = 0x{connId}",
table.Where(_ => new Col<Row, ConnectionId>("T", "Name").Eq(connId)).Build().Sql
);
var uuid = Uuid.Parse("00000000-0000-0000-0000-000000000000");
var uuidHex = uuid.ToString().Replace("-", string.Empty);
Assert.Equal(
$"SELECT * FROM \"T\" WHERE \"T\".\"Name\" = 0x{uuidHex}",
table.Where(_ => new Col<Row, Uuid>("T", "Name").Eq(uuid)).Build().Sql
);
var u128 = new U128(upper: 0, lower: 5);
Assert.Equal(
$"SELECT * FROM \"T\" WHERE \"T\".\"Name\" = 5",
table.Where(_ => new Col<Row, U128>("T", "Name").Eq(u128)).Build().Sql
);
}
[Fact]
public void IxCol_EqNeq_FormatsCorrectly()
{
var ix = new IxCol<Row, string>("T", "Name");
Assert.Equal(
"\"T\".\"Name\" = 'x'",
ix.Eq("x").Sql
);
Assert.Equal(
"\"T\".\"Name\" <> 'x'",
ix.Neq("x").Sql
);
}
[Fact]
public void LeftSemijoin_Build_FormatsCorrectly()
{
var left = MakeLeftTable("users");
var right = MakeRightTable("other");
var sql = left.LeftSemijoin(right, (l, r) => l.Id.Eq(r.Uid)).Build().Sql;
Assert.Equal(
"SELECT \"users\".* FROM \"users\" JOIN \"other\" ON \"users\".\"id\" = \"other\".\"uid\"",
sql
);
}
[Fact]
public void Where_NullableCol_Eq_FormatsCorrectly()
{
var table = MakeNullableTable("T");
var sql = table.Where(c => c.Name.Eq("x")).Build().Sql;
Assert.Equal("SELECT * FROM \"T\" WHERE \"T\".\"Name\" = 'x'", sql);
}
[Fact]
public void Where_NullableCol_Gt_FormatsCorrectly()
{
var table = MakeNullableTable("T");
var sql = table.Where(c => c.Age.Gt(123)).Build().Sql;
Assert.Equal("SELECT * FROM \"T\" WHERE \"T\".\"Age\" > 123", sql);
}
[Fact]
public void RightSemijoin_WithLeftAndRightWhere_FormatsCorrectly()
{
var left = MakeLeftTable("users");
var right = MakeRightTable("other");
var sql = left
.Where(c => c.Id.Eq(1))
.RightSemijoin(right, (l, r) => l.Id.Eq(r.Uid))
.Where(c => c.Uid.Gt(10))
.Build()
.Sql;
Assert.Equal(
"SELECT \"other\".* FROM \"users\" JOIN \"other\" ON \"users\".\"id\" = \"other\".\"uid\" WHERE \"users\".\"id\" = 1 AND \"other\".\"uid\" > 10",
sql
);
}
}