Skip to content

Commit 85095cf

Browse files
authored
[C#] Removes Query<TRow> and .Build() in favor of IQuery<TRow> (#4393)
This fixes an issue with the C# implementation of Query Builder requiring `Build()` to return a query, and the return type being `Query<TRow>` rather than `IQuery<TRow>`. # Description of Changes 1. Runtime/query-builder * Removed the concrete `Query<TRow>` carrier type and every `.Build()`. Query builder shapes now expose only `IQuery<TRow>` plus `ToSql()`. * Ensured all builder entry points (tables, joins, filters) continue to return `IQuery<TRow>`. 2. Source generator + bindings * Updated `ViewDeclaration` analysis to treat any return type implementing `SpacetimeDB.IQuery<TRow>` as a SQL view. * Dispatcher generation now emits `ViewResultHeader.RawSql(returnValue.ToSql())` . This eliminates a `Query<TRow>` special-case. 3. Tests, fixtures, regression module * Converted the C# query-builder unit tests, codegen fixtures, and regression-test server views to call `ToSql()`/return `IQuery<TRow>`. * Added coverage proving `RightSemiJoin` (and friends) still satisfy `IQuery<TRow>`. 4. CLI templates & generated bindings * Regenerated/edited C# template bindings so `SubscriptionBuilder.AddQuery` accepts `Func<QueryBuilder, IQuery<TRow>>` and captures SQL via `ToSql()`. # API and ABI breaking changes While technically API breaking, this actually brings the API closer to the intended design. * `Query<TRow>` has been removed from the public surface area; any previous references (including `.Build()` and `.Sql`) must be replaced with the builder instance itself plus `.ToSql()`. * View methods must now return an `IQuery<TRow>` (or any custom type implementing it) when producing SQL for the host. * Generated C# client bindings now expect typed subscription callbacks to produce `IQuery<TRow>`, aligning the client SDK with the new runtime contract. # Expected complexity level and risk 3 - Medium: Touches runtime, codegen, fixtures, and templates. Risk is mitigated by parity with Rust semantics and comprehensive test updates, but downstream modules must recompile to adopt the new interface. # Testing - [X] Built CLI and ran regression tests locally with removed `.Build()` - [X] Ran `dotnet test .\sdks\csharp\tests~\tests.csproj -c Release` with all tests passing - [X] Ran `dotnet test .\crates\bindings-csharp\Codegen.Tests\Codegen.Tests.csproj -c Release` with all tests passing
1 parent 8aa22da commit 85095cf

7 files changed

Lines changed: 70 additions & 76 deletions

File tree

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

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,6 @@ public interface IQuery<TRow>
6565
string ToSql();
6666
}
6767

68-
public readonly struct Query<TRow> : IQuery<TRow>
69-
{
70-
public string Sql { get; }
71-
72-
public Query(string sql)
73-
{
74-
Sql = sql;
75-
}
76-
77-
public string ToSql() => Sql;
78-
79-
public override string ToString() => Sql;
80-
}
81-
8268
public readonly struct BoolExpr<TRow>
8369
{
8470
public string Sql { get; }
@@ -275,8 +261,6 @@ public Table(string tableName, TCols cols, TIxCols ixCols)
275261

276262
public string ToSql() => $"SELECT * FROM {SqlFormat.QuoteIdent(tableName)}";
277263

278-
public Query<TRow> Build() => new(ToSql());
279-
280264
public FromWhere<TRow, TCols, TIxCols> Where(Func<TCols, BoolExpr<TRow>> predicate) =>
281265
new(this, predicate(cols));
282266

@@ -333,8 +317,6 @@ public FromWhere<TRow, TCols, TIxCols> Filter(Func<TCols, TIxCols, BoolExpr<TRow
333317

334318
public string ToSql() => $"{table.ToSql()} WHERE {expr.Sql}";
335319

336-
public Query<TRow> Build() => new(ToSql());
337-
338320
public LeftSemiJoin<TRow, TCols, TIxCols, TRightRow, TRightCols, TRightIxCols> LeftSemijoin<
339321
TRightRow,
340322
TRightCols,
@@ -444,8 +426,6 @@ public string ToSql()
444426
var whereClause = whereExpr.HasValue ? $" WHERE {whereExpr.Value.Sql}" : string.Empty;
445427
return $"SELECT {left.TableRefSql}.* FROM {left.TableRefSql} JOIN {right.TableRefSql} ON {leftJoinRefSql} = {rightJoinRefSql}{whereClause}";
446428
}
447-
448-
public Query<TLeftRow> Build() => new(ToSql());
449429
}
450430

451431
public sealed class RightSemiJoin<
@@ -569,8 +549,6 @@ public string ToSql()
569549

570550
return $"SELECT {right.TableRefSql}.* FROM {left.TableRefSql} JOIN {right.TableRefSql} ON {leftJoinRefSql} = {rightJoinRefSql}{whereClause}";
571551
}
572-
573-
public Query<TRightRow> Build() => new(ToSql());
574552
}
575553

576554
public static class QueryBuilderExtensions

crates/bindings-csharp/Codegen.Tests/fixtures/client/Lib.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,18 +121,18 @@ private static Table<PublicTable, PublicTableCols, PublicTableIxCols> MakeTable(
121121
}
122122

123123
private static string BuildPublicTableQuerySql() =>
124-
MakeTable().Where(cols => cols.Id.Eq(0)).Build().Sql;
124+
MakeTable().Where(cols => cols.Id.Eq(0)).ToSql();
125125

126126
private static string BuildPublicTableViewSql()
127127
{
128128
var cols = new PublicTableCols("PublicTable");
129-
return MakeTable().Where(_ => cols.Id.Eq(0)).Build().Sql;
129+
return MakeTable().Where(_ => cols.Id.Eq(0)).ToSql();
130130
}
131131

132132
private static string BuildFindPublicTableByIdentitySql()
133133
{
134134
var table = MakeTable();
135-
return table.Where(cols => cols.Id.Eq(0)).Build().Sql;
135+
return table.Where(cols => cols.Id.Eq(0)).ToSql();
136136
}
137137

138138
/// <summary>

crates/bindings-csharp/Codegen.Tests/fixtures/server/Lib.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,9 +281,9 @@ public class Module
281281
}
282282

283283
[SpacetimeDB.View(Accessor = "public_table_query", Public = true)]
284-
public static Query<PublicTable> PublicTableQuery(ViewContext ctx)
284+
public static IQuery<PublicTable> PublicTableQuery(ViewContext ctx)
285285
{
286-
return ctx.From.PublicTable().Where(cols => cols.Id.Eq(0)).Build();
286+
return ctx.From.PublicTable().Where(cols => cols.Id.Eq(0));
287287
}
288288

289289
[SpacetimeDB.View(Accessor = "find_public_table__by_identity", Public = true)]

crates/bindings-csharp/Codegen/Module.cs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,14 +1155,33 @@ public ViewDeclaration(GeneratorAttributeSyntaxContext context, DiagReporter dia
11551155
IsAnonymous = isAnonymousContext;
11561156

11571157
ReturnsQuery = false;
1158+
INamedTypeSymbol? iquery = null;
11581159
if (
11591160
method.ReturnType is INamedTypeSymbol
11601161
{
1161-
Name: "Query",
1162+
Name: "IQuery",
11621163
ContainingNamespace: { Name: "SpacetimeDB" },
1163-
TypeArguments: [var queryRowType]
1164-
}
1164+
TypeArguments: [var _]
1165+
} directIQuery
11651166
)
1167+
{
1168+
iquery = directIQuery;
1169+
}
1170+
else
1171+
{
1172+
iquery = method
1173+
.ReturnType.AllInterfaces.OfType<INamedTypeSymbol>()
1174+
.FirstOrDefault(i =>
1175+
i
1176+
is {
1177+
Name: "IQuery",
1178+
ContainingNamespace: { Name: "SpacetimeDB" },
1179+
TypeArguments.Length: 1
1180+
}
1181+
);
1182+
}
1183+
1184+
if (iquery is { TypeArguments: [var queryRowType] })
11661185
{
11671186
ReturnsQuery = true;
11681187
var rowType = TypeUse.Parse(method, queryRowType, diag);

sdks/csharp/examples~/regression-tests/client/Program.cs

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -66,48 +66,46 @@ void OnConnected(DbConnection conn, Identity identity, string authToken)
6666
throw err;
6767
}
6868
)
69-
.AddQuery(qb => qb.From.ExampleData().Build())
70-
.AddQuery(qb => qb.From.MyPlayer().Build())
71-
.AddQuery(qb => qb.From.MyAccount().Build())
72-
.AddQuery(qb => qb.From.MyAccountMissing().Build())
73-
.AddQuery(qb => qb.From.PlayersAtLevelOne().Build())
74-
.AddQuery(qb => qb.From.MyTable().Build())
75-
.AddQuery(qb => qb.From.NullStringNonnullable().Build())
76-
.AddQuery(qb => qb.From.NullStringNullable().Build())
77-
.AddQuery(qb => qb.From.MyLog().Build())
78-
.AddQuery(qb => qb.From.TestEvent().Build())
79-
.AddQuery(qb => qb.From.Admins().Build())
80-
.AddQuery(qb => qb.From.NullableVecView().Build())
81-
.AddQuery(qb => qb.From.WhereTest().Where(c => c.Value.Gt(10)).Build())
69+
.AddQuery(qb => qb.From.ExampleData())
70+
.AddQuery(qb => qb.From.MyPlayer())
71+
.AddQuery(qb => qb.From.MyAccount())
72+
.AddQuery(qb => qb.From.MyAccountMissing())
73+
.AddQuery(qb => qb.From.PlayersAtLevelOne())
74+
.AddQuery(qb => qb.From.MyTable())
75+
.AddQuery(qb => qb.From.NullStringNonnullable())
76+
.AddQuery(qb => qb.From.NullStringNullable())
77+
.AddQuery(qb => qb.From.MyLog())
78+
.AddQuery(qb => qb.From.TestEvent())
79+
.AddQuery(qb => qb.From.Admins())
80+
.AddQuery(qb => qb.From.NullableVecView())
81+
.AddQuery(qb => qb.From.WhereTest().Where(c => c.Value.Gt(10)))
8282
.AddQuery(qb =>
8383
qb.From.Player()
8484
.LeftSemijoin(qb.From.PlayerLevel(), (p, pl) => p.Id.Eq(pl.PlayerId))
85-
.Build()
8685
)
8786
.AddQuery(qb =>
8887
qb.From.Player()
8988
.Where(c => c.Name.Eq("NewPlayer"))
9089
.RightSemijoin(qb.From.PlayerLevel(), (p, pl) => p.Id.Eq(pl.PlayerId))
9190
.Where(c => c.Level.Eq(1UL))
92-
.Build()
9391
)
94-
.AddQuery(qb => qb.From.UsersNamedAlice().Build())
95-
.AddQuery(qb => qb.From.UsersAge1865().Build())
96-
.AddQuery(qb => qb.From.UsersAge18Plus().Build())
97-
.AddQuery(qb => qb.From.UsersAgeUnder18().Build())
98-
.AddQuery(qb => qb.From.ScoresPlayer123().Build())
99-
.AddQuery(qb => qb.From.ScoresPlayer123Range().Build())
100-
.AddQuery(qb => qb.From.ScoresPlayer123Level5().Build())
92+
.AddQuery(qb => qb.From.UsersNamedAlice())
93+
.AddQuery(qb => qb.From.UsersAge1865())
94+
.AddQuery(qb => qb.From.UsersAge18Plus())
95+
.AddQuery(qb => qb.From.UsersAgeUnder18())
96+
.AddQuery(qb => qb.From.ScoresPlayer123())
97+
.AddQuery(qb => qb.From.ScoresPlayer123Range())
98+
.AddQuery(qb => qb.From.ScoresPlayer123Level5())
10199
.AddQuery(qb =>
102100
qb.From.User()
103101
.Where(c => c.Age.Gte((byte)18).And(c.Age.Lt((byte)65)))
104102
.Where(c => c.IsAdmin.Eq(true).Or(c.Name.Eq("Charlie")))
105-
.Build()
103+
.Filter(c => c.Name.Neq("BOT"))
106104
)
107-
.AddQuery(qb => qb.From.Score().Build())
108-
.AddQuery(qb => qb.From.WhereTestView().Build())
109-
.AddQuery(qb => qb.From.FindWhereTest().Build())
110-
.AddQuery(qb => qb.From.WhereTestQuery().Build())
105+
.AddQuery(qb => qb.From.Score())
106+
.AddQuery(qb => qb.From.WhereTestView())
107+
.AddQuery(qb => qb.From.FindWhereTest())
108+
.AddQuery(qb => qb.From.WhereTestQuery())
111109
.Subscribe();
112110

113111
// If testing against Rust, the indexed parameter will need to be changed to: ulong indexed

sdks/csharp/examples~/regression-tests/server/Lib.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,9 @@ public static List<Score> ScoresPlayer123Level5(AnonymousViewContext ctx)
307307
}
308308

309309
[SpacetimeDB.View(Accessor = "where_test_query", Public = true)]
310-
public static Query<WhereTest> WhereTestQuery(ViewContext ctx)
310+
public static IQuery<WhereTest> WhereTestQuery(ViewContext ctx)
311311
{
312-
return ctx.From.where_test().Where(cols => cols.Id.Eq(SqlLit.Int(2u))).Build();
312+
return ctx.From.where_test().Where(cols => cols.Id.Eq(SqlLit.Int(2u)));
313313
}
314314

315315
[SpacetimeDB.View(Accessor = "find_where_test", Public = true)]

sdks/csharp/tests~/QueryBuilderTests.cs

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -141,22 +141,22 @@ private static Table<RightRow, RightCols, RightNullableIxCols> MakeRightNullable
141141
public void All_QuotesTableName()
142142
{
143143
var table = MakeTable("My\"Table");
144-
Assert.Equal("SELECT * FROM \"My\"\"Table\"", table.Build().Sql);
144+
Assert.Equal("SELECT * FROM \"My\"\"Table\"", table.ToSql());
145145
}
146146

147147
[Fact]
148148
public void Where_Eq_String_EscapesSingleQuote()
149149
{
150150
var table = MakeTable("T");
151-
var sql = table.Where(c => c.Name.Eq("O'Reilly")).Build().Sql;
151+
var sql = table.Where(c => c.Name.Eq("O'Reilly")).ToSql();
152152
Assert.Equal("SELECT * FROM \"T\" WHERE (\"T\".\"Name\" = 'O''Reilly')", sql);
153153
}
154154

155155
[Fact]
156156
public void Where_Gt_Int_FormatsInvariant()
157157
{
158158
var table = MakeTable("T");
159-
var sql = table.Where(c => c.Age.Gt(123)).Build().Sql;
159+
var sql = table.Where(c => c.Age.Gt(123)).ToSql();
160160
Assert.Equal("SELECT * FROM \"T\" WHERE (\"T\".\"Age\" > 123)", sql);
161161
}
162162

@@ -166,27 +166,27 @@ public void Where_Eq_Bool_FormatsAsTrueFalse()
166166
var table = MakeTable("T");
167167
Assert.Equal(
168168
"SELECT * FROM \"T\" WHERE (\"T\".\"IsAdmin\" = TRUE)",
169-
table.Where(c => c.IsAdmin.Eq(true)).Build().Sql
169+
table.Where(c => c.IsAdmin.Eq(true)).ToSql()
170170
);
171171
Assert.Equal(
172172
"SELECT * FROM \"T\" WHERE (\"T\".\"IsAdmin\" = FALSE)",
173-
table.Where(c => c.IsAdmin.Eq(false)).Build().Sql
173+
table.Where(c => c.IsAdmin.Eq(false)).ToSql()
174174
);
175175
}
176176

177177
[Fact]
178178
public void Where_WithIxColsOverload_FormatsCorrectly()
179179
{
180180
var table = MakeTable("T");
181-
var sql = table.Where((_, ix) => ix.Name.Eq(SqlLit.String("x"))).Build().Sql;
181+
var sql = table.Where((_, ix) => ix.Name.Eq(SqlLit.String("x"))).ToSql();
182182
Assert.Equal("SELECT * FROM \"T\" WHERE (\"T\".\"Name\" = 'x')", sql);
183183
}
184184

185185
[Fact]
186186
public void Where_ChainingWhere_AddsAnd()
187187
{
188188
var table = MakeTable("T");
189-
var sql = table.Where(c => c.Age.Gt(1)).Where(c => c.IsAdmin.Eq(true)).Build().Sql;
189+
var sql = table.Where(c => c.Age.Gt(1)).Where(c => c.IsAdmin.Eq(true)).ToSql();
190190

191191
Assert.Equal(
192192
"SELECT * FROM \"T\" WHERE ((\"T\".\"Age\" > 1) AND (\"T\".\"IsAdmin\" = TRUE))",
@@ -212,7 +212,7 @@ public void BoolExpr_AndOrNot_AddsParens()
212212
public void QuoteIdent_EscapesDoubleQuotesInColumnName()
213213
{
214214
var table = MakeTable("T");
215-
var sql = table.Where(c => c.Weird.Eq("x")).Build().Sql;
215+
var sql = table.Where(c => c.Weird.Eq("x")).ToSql();
216216
Assert.Equal("SELECT * FROM \"T\" WHERE (\"T\".\"we\"\"ird\" = 'x')", sql);
217217
}
218218

@@ -224,26 +224,26 @@ public void FormatLiteral_SpacetimeDbTypes_AreQuoted()
224224
var identity = Identity.FromHexString(new string('0', 64));
225225
Assert.Equal(
226226
$"SELECT * FROM \"T\" WHERE (\"T\".\"Name\" = 0x{identity})",
227-
table.Where(_ => new Col<Row, Identity>("T", "Name").Eq(identity)).Build().Sql
227+
table.Where(_ => new Col<Row, Identity>("T", "Name").Eq(identity)).ToSql()
228228
);
229229

230230
var connId = ConnectionId.FromHexString(new string('0', 31) + "1") ?? throw new InvalidOperationException();
231231
Assert.Equal(
232232
$"SELECT * FROM \"T\" WHERE (\"T\".\"Name\" = 0x{connId})",
233-
table.Where(_ => new Col<Row, ConnectionId>("T", "Name").Eq(connId)).Build().Sql
233+
table.Where(_ => new Col<Row, ConnectionId>("T", "Name").Eq(connId)).ToSql()
234234
);
235235

236236
var uuid = Uuid.Parse("00000000-0000-0000-0000-000000000000");
237237
var uuidHex = uuid.ToString().Replace("-", string.Empty);
238238
Assert.Equal(
239239
$"SELECT * FROM \"T\" WHERE (\"T\".\"Name\" = 0x{uuidHex})",
240-
table.Where(_ => new Col<Row, Uuid>("T", "Name").Eq(uuid)).Build().Sql
240+
table.Where(_ => new Col<Row, Uuid>("T", "Name").Eq(uuid)).ToSql()
241241
);
242242

243243
var u128 = new U128(upper: 0, lower: 5);
244244
Assert.Equal(
245245
$"SELECT * FROM \"T\" WHERE (\"T\".\"Name\" = 5)",
246-
table.Where(_ => new Col<Row, U128>("T", "Name").Eq(u128)).Build().Sql
246+
table.Where(_ => new Col<Row, U128>("T", "Name").Eq(u128)).ToSql()
247247
);
248248
}
249249

@@ -268,7 +268,7 @@ public void LeftSemijoin_Build_FormatsCorrectly()
268268
var left = MakeLeftTable("users");
269269
var right = MakeRightTable("other");
270270

271-
var sql = left.LeftSemijoin(right, (l, r) => l.Id.Eq(r.Uid)).Build().Sql;
271+
var sql = left.LeftSemijoin(right, (l, r) => l.Id.Eq(r.Uid)).ToSql();
272272
Assert.Equal(
273273
"SELECT \"users\".* FROM \"users\" JOIN \"other\" ON \"users\".\"id\" = \"other\".\"uid\"",
274274
sql
@@ -279,15 +279,15 @@ public void LeftSemijoin_Build_FormatsCorrectly()
279279
public void Where_NullableCol_Eq_FormatsCorrectly()
280280
{
281281
var table = MakeNullableTable("T");
282-
var sql = table.Where(c => c.Name.Eq("x")).Build().Sql;
282+
var sql = table.Where(c => c.Name.Eq("x")).ToSql();
283283
Assert.Equal("SELECT * FROM \"T\" WHERE (\"T\".\"Name\" = 'x')", sql);
284284
}
285285

286286
[Fact]
287287
public void Where_NullableCol_Gt_FormatsCorrectly()
288288
{
289289
var table = MakeNullableTable("T");
290-
var sql = table.Where(c => c.Age.Gt(123)).Build().Sql;
290+
var sql = table.Where(c => c.Age.Gt(123)).ToSql();
291291
Assert.Equal("SELECT * FROM \"T\" WHERE (\"T\".\"Age\" > 123)", sql);
292292
}
293293

@@ -301,8 +301,7 @@ public void RightSemijoin_WithLeftAndRightWhere_FormatsCorrectly()
301301
.Where(c => c.Id.Eq(1))
302302
.RightSemijoin(right, (l, r) => l.Id.Eq(r.Uid))
303303
.Where(c => c.Uid.Gt(10))
304-
.Build()
305-
.Sql;
304+
.ToSql();
306305

307306
Assert.Equal(
308307
"SELECT \"other\".* FROM \"users\" JOIN \"other\" ON \"users\".\"id\" = \"other\".\"uid\" WHERE (\"users\".\"id\" = 1) AND (\"other\".\"uid\" > 10)",

0 commit comments

Comments
 (0)