Files
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

747 lines
29 KiB
C#

/// Regression tests run with a live server.
/// To run these, run a local SpacetimeDB via `spacetime start`,
/// then in a separate terminal run `tools~/run-regression-tests.sh PATH_TO_SPACETIMEDB_REPO_CHECKOUT`.
/// This is done on CI in .github/workflows/test.yml.
using System;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using SpacetimeDB;
using SpacetimeDB.Types;
const string HOST = "http://localhost:3000";
const string DBNAME = "btree-repro";
const string THROW_ERROR_MESSAGE = "this is an error";
DbConnection ConnectToDB()
{
DbConnection? conn = null;
conn = DbConnection
.Builder()
.WithUri(HOST)
.WithModuleName(DBNAME)
.OnConnect(OnConnected)
.OnConnectError(
(err) =>
{
throw err;
}
)
.OnDisconnect(
(conn, err) =>
{
if (err != null)
{
throw err;
}
else
{
throw new Exception("Unexpected disconnect");
}
}
)
.Build();
return conn;
}
uint waiting = 0;
var applied = false;
SubscriptionHandle? handle = null;
void OnConnected(DbConnection conn, Identity identity, string authToken)
{
Log.Debug($"Connected to {DBNAME} on {HOST}");
handle = conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.OnError(
(ctx, err) =>
{
throw err;
}
)
.AddQuery(qb => qb.From.ExampleData().Build())
.AddQuery(qb => qb.From.MyPlayer().Build())
.AddQuery(qb => qb.From.MyAccount().Build())
.AddQuery(qb => qb.From.MyAccountMissing().Build())
.AddQuery(qb => qb.From.PlayersAtLevelOne().Build())
.AddQuery(qb => qb.From.MyTable().Build())
.AddQuery(qb => qb.From.NullStringNonnullable().Build())
.AddQuery(qb => qb.From.NullStringNullable().Build())
.AddQuery(qb => qb.From.MyLog().Build())
.AddQuery(qb => qb.From.Admins().Build())
.AddQuery(qb => qb.From.NullableVecView().Build())
.AddQuery(qb => qb.From.WhereTest().Where(c => c.Value.Gt(10)).Build())
.AddQuery(
qb => qb.From.Player()
.LeftSemijoin(qb.From.PlayerLevel(), (p, pl) => p.Id.Eq(pl.PlayerId))
.Build()
)
.AddQuery(
qb => qb.From.Player()
.Where(c => c.Name.Eq("NewPlayer"))
.RightSemijoin(qb.From.PlayerLevel(), (p, pl) => p.Id.Eq(pl.PlayerId))
.Where(c => c.Level.Eq(1UL))
.Build()
)
.Subscribe();
// If testing against Rust, the indexed parameter will need to be changed to: ulong indexed
conn.Reducers.OnAdd += (ReducerEventContext ctx, uint id, uint indexed) =>
{
Log.Info("Got Add callback");
waiting--;
ValidateBTreeIndexes(ctx);
};
conn.Reducers.OnDelete += (ReducerEventContext ctx, uint id) =>
{
Log.Info("Got Delete callback");
waiting--;
ValidateBTreeIndexes(ctx);
};
conn.Reducers.OnInsertResult += (ReducerEventContext ctx, Result<MyTable, string> msg) =>
{
Log.Info($"Got InsertResult callback: {msg}");
waiting--;
};
conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception exception) =>
{
Log.Info($"Got OnUnhandledReducerError: {exception}");
waiting--;
ValidateReducerErrorDoesNotContainStackTrace(exception);
ValidateBTreeIndexes(ctx);
ValidateNullableVecView(ctx);
};
conn.Reducers.OnSetNullableVec += (ReducerEventContext ctx, uint id, bool hasPos, int x, int y) =>
{
Log.Info("Got SetNullableVec callback");
waiting--;
if (id == 1)
{
ValidateNullableVecView(ctx, hasPos, x, y);
}
else
{
ValidateNullableVecView(ctx);
}
};
conn.Reducers.OnInsertEmptyStringIntoNonNullable += (ReducerEventContext ctx) =>
{
Log.Info("Got InsertEmptyStringIntoNonNullable callback");
waiting--;
Debug.Assert(
ctx.Event.Status is Status.Committed,
$"InsertEmptyStringIntoNonNullable should commit, got {ctx.Event.Status}"
);
Debug.Assert(
ctx.Db.NullStringNonnullable.Iter().Any(r => r.Name == ""),
"Expected a row inserted into null_string_nonnullable with Name == \"\""
);
};
conn.Reducers.OnInsertNullStringIntoNonNullable += (ReducerEventContext ctx) =>
{
Log.Info("Got InsertNullStringIntoNonNullable callback");
waiting--;
if (ctx.Event.Status is Status.Failed(var reason))
{
Debug.Assert(
reason.Contains("Cannot serialize a null string", StringComparison.OrdinalIgnoreCase)
|| reason.Contains("BSATN", StringComparison.OrdinalIgnoreCase)
|| reason.Contains("nullable string", StringComparison.OrdinalIgnoreCase),
$"Expected a serialization-related failure message, got: {reason}"
);
}
else
{
throw new Exception(
$"InsertNullStringIntoNonNullable should fail, got status {ctx.Event.Status}"
);
}
};
conn.Reducers.OnInsertNullStringIntoNullable += (ReducerEventContext ctx) =>
{
Log.Info("Got InsertNullStringIntoNullable callback");
waiting--;
Debug.Assert(
ctx.Event.Status is Status.Committed,
$"InsertNullStringIntoNullable should commit, got {ctx.Event.Status}"
);
Debug.Assert(
ctx.Db.NullStringNullable.Iter().Any(r => r.Name == null),
"Expected a row inserted into null_string_nullable with Name == null"
);
};
}
const uint MAX_ID = 10;
// Test that indexes remain in sync with the expected table state when deletes are received.
// This used to fail, when row types did not correctly implement IEquatable.
void ValidateBTreeIndexes(IRemoteDbContext conn)
{
Log.Debug("Checking indexes...");
foreach (var data in conn.Db.ExampleData.Iter())
{
Debug.Assert(conn.Db.ExampleData.Indexed.Filter(data.Id).Contains(data));
}
var outOfIndex = conn.Db.ExampleData.Iter().ToHashSet();
for (uint i = 0; i < MAX_ID; i++)
{
foreach (var data in conn.Db.ExampleData.Indexed.Filter(i))
{
Debug.Assert(outOfIndex.Contains(data));
}
}
Log.Debug(" Indexes are good.");
}
void ValidateNullableVecView(
IRemoteDbContext conn,
bool? expectedHasPos = null,
int expectedX = 0,
int expectedY = 0
)
{
Log.Debug("Checking nullable vec view...");
Debug.Assert(conn.Db.NullableVecView != null, "conn.Db.NullableVecView != null");
Debug.Assert(
conn.Db.NullableVecView.Count >= 2,
$"conn.Db.NullableVecView.Count = {conn.Db.NullableVecView.Count}"
);
var rows = conn.Db.NullableVecView.Iter().ToList();
Debug.Assert(rows.Any(r => r.Id == 1));
Debug.Assert(rows.Any(r => r.Id == 2));
var remoteRows = conn.Db.NullableVecView.RemoteQuery("WHERE Id = 1").Result;
Debug.Assert(remoteRows != null && remoteRows.Length == 1);
Debug.Assert(remoteRows[0].Id == 1);
if (expectedHasPos is bool hasPos)
{
var row1 = rows.First(r => r.Id == 1);
if (!hasPos)
{
Debug.Assert(row1.Pos == null, "Expected NullableVecView row 1 Pos == null");
}
else
{
Debug.Assert(row1.Pos != null, "Expected NullableVecView row 1 Pos != null");
Debug.Assert(row1.Pos.X == expectedX, $"Expected row1.Pos.X == {expectedX}, got {row1.Pos.X}");
Debug.Assert(row1.Pos.Y == expectedY, $"Expected row1.Pos.Y == {expectedY}, got {row1.Pos.Y}");
}
}
}
void ValidateReducerErrorDoesNotContainStackTrace(Exception exception)
{
Debug.Assert(
exception.Message == THROW_ERROR_MESSAGE,
$"Expected reducer error message '{THROW_ERROR_MESSAGE}', got '{exception.Message}'"
);
Debug.Assert(!exception.Message.Contains("\n"), "Reducer error message should not contain newline");
Debug.Assert(!exception.Message.Contains("\r"), "Reducer error message should not contain newline");
Debug.Assert(!exception.Message.Contains(" at "), "Reducer error message should not contain stack trace");
}
void ValidateWhereSubscription(IRemoteDbContext conn)
{
Log.Debug("Checking typed WHERE subscription...");
Debug.Assert(conn.Db.WhereTest != null, "conn.Db.WhereTest != null");
var rows = conn.Db.WhereTest.Iter().ToList();
Debug.Assert(rows.Count == 2, $"Expected 2 where_test rows, got {rows.Count}");
Debug.Assert(rows.All(r => r.Value > 10), "Expected all where_test.Value > 10");
Debug.Assert(rows.Any(r => r.Id == 2 && r.Name == "high"), "Expected where_test row id=2 name=high");
Debug.Assert(rows.Any(r => r.Id == 3 && r.Name == "alsohigh"), "Expected where_test row id=3 name=alsohigh");
}
void ValidateSemijoinSubscriptions(IRemoteDbContext conn, Identity identity)
{
Log.Debug("Checking typed semijoin subscriptions...");
var players = conn.Db.Player.Iter().ToList();
Debug.Assert(players.Count == 1, $"Expected 1 player row, got {players.Count}");
Debug.Assert(players[0].Identity == identity, "Expected player.Identity to match the connection identity");
Debug.Assert(players[0].Name == "NewPlayer", $"Expected player.Name == NewPlayer, got {players[0].Name}");
var playerId = players[0].Id;
var levels = conn.Db.PlayerLevel.Iter().ToList();
Debug.Assert(levels.Count == 1, $"Expected 1 player_level row, got {levels.Count}");
Debug.Assert(levels[0].PlayerId == playerId, "Expected player_level.PlayerId to match the subscribed player id");
Debug.Assert(levels[0].Level == 1, $"Expected player_level.Level == 1, got {levels[0].Level}");
}
void OnSubscriptionApplied(SubscriptionEventContext context)
{
applied = true;
ValidateWhereSubscription(context);
ValidateSemijoinSubscriptions(context, context.Identity!.Value);
// Do some operations that alter row state;
// we will check that everything is in sync in the callbacks for these reducer calls.
Log.Debug("Calling Add");
waiting++;
context.Reducers.Add(1, 1);
Log.Debug("Calling Delete");
waiting++;
context.Reducers.Delete(1);
Log.Debug("Calling Add");
waiting++;
context.Reducers.Add(1, 1);
Log.Debug("Calling ThrowError");
waiting++;
context.Reducers.ThrowError(THROW_ERROR_MESSAGE);
Log.Debug("Calling InsertResult");
waiting++;
context.Reducers.InsertResult(Result<MyTable, string>.Ok(new MyTable(new ReturnStruct(42, "magic"))));
waiting++;
context.Reducers.InsertResult(Result<MyTable, string>.Err("Fail"));
Log.Debug("Calling RemoteQuery on my_log");
var logRows = context.Db.MyLog.RemoteQuery("").Result;
Debug.Assert(logRows != null && logRows.Length == 2);
var logs = logRows.ToArray();
var expected = new[]
{
new MyLog(Result<MyTable, string>.Ok(
new MyTable(new ReturnStruct(42, "magic"))
)),
new MyLog(Result<MyTable, string>.Err("Fail")),
};
Debug.Assert(
logs.SequenceEqual(expected),
"Logs did not match expected results"
);
// RemoteQuery test
Log.Debug("Calling RemoteQuery");
// If testing against Rust, the query will need to be changed to "WHERE id = 0"
var remoteRows = context.Db.ExampleData.RemoteQuery("WHERE Id = 1").Result;
Debug.Assert(remoteRows != null && remoteRows.Length > 0);
Log.Debug("Calling Admins.RemoteQuery");
var remoteAdminRows = context.Db.Admins.RemoteQuery("WHERE IsAdmin = true").Result;
Debug.Assert(remoteAdminRows != null && remoteAdminRows.Length > 0);
// Views test
Log.Debug("Checking Views are populated");
Debug.Assert(context.Db.MyPlayer != null, "context.Db.MyPlayer != null");
Debug.Assert(context.Db.MyAccount != null, "context.Db.MyAccount != null");
Debug.Assert(context.Db.MyAccountMissing != null, "context.Db.MyAccountMissing != null");
Debug.Assert(context.Db.PlayersAtLevelOne != null, "context.Db.PlayersAtLevelOne != null");
Debug.Assert(
context.Db.MyPlayer.Count > 0,
$"context.Db.MyPlayer.Count = {context.Db.MyPlayer.Count}"
);
Debug.Assert(
context.Db.MyAccount.Count == 1,
$"context.Db.MyAccount.Count = {context.Db.MyAccount.Count}"
);
Debug.Assert(
context.Db.MyAccountMissing.Count == 0,
$"context.Db.MyAccountMissing.Count = {context.Db.MyAccountMissing.Count}"
);
Debug.Assert(
context.Db.PlayersAtLevelOne.Count > 0,
$"context.Db.PlayersAtLevelOne.Count = {context.Db.PlayersAtLevelOne.Count}"
);
Debug.Assert(context.Db.Admins != null, "context.Db.Admins != null");
Debug.Assert(context.Db.Admins.Count > 0, $"context.Db.Admins.Count = {context.Db.Admins.Count}");
ValidateNullableVecView(context, expectedHasPos: true, expectedX: 1, expectedY: 2);
Log.Debug("Calling Iter on View");
var viewIterRows = context.Db.MyPlayer.Iter();
var expectedPlayer = new Player
{
Id = 1,
Identity = context.Identity!.Value,
Name = "NewPlayer",
};
Log.Debug(
"MyPlayer Iter count: " + (viewIterRows != null ? viewIterRows.Count().ToString() : "null")
);
Debug.Assert(viewIterRows != null && viewIterRows.Any());
Log.Debug(
"Validating View row data "
+ $"Id={expectedPlayer.Id}, Identity={expectedPlayer.Identity}, Name={expectedPlayer.Name} => "
+ $"Id={viewIterRows.First().Id}, Identity={viewIterRows.First().Identity}, Name={viewIterRows.First().Name}"
);
Debug.Assert(viewIterRows.First().Equals(expectedPlayer));
Log.Debug("Calling Iter on View Admins");
var adminsIterRows = context.Db.Admins.Iter();
var expectedAdminNames = new HashSet<string> { "Alice", "Charlie" };
Log.Debug("Admins Iter count: " + (adminsIterRows != null ? adminsIterRows.Count().ToString() : "null"));
Debug.Assert(adminsIterRows != null && adminsIterRows.Any());
Log.Debug("Validating Admins View row data " +
$"Expected Names={string.Join(", ", expectedAdminNames)} => " +
$"Actual Names={string.Join(", ", adminsIterRows.Select(a => a.Name))}");
Debug.Assert(adminsIterRows.All(a => expectedAdminNames.Contains(a.Name)));
Log.Debug("Calling RemoteQuery on View");
// If testing against Rust, the query will need to be changed to "WHERE id > 0"
var viewRemoteQueryRows = context.Db.MyPlayer.RemoteQuery("WHERE Id > 0");
Debug.Assert(viewRemoteQueryRows != null && viewRemoteQueryRows.Result.Length > 0);
Debug.Assert(viewRemoteQueryRows.Result.First().Equals(expectedPlayer));
Log.Debug("Calling Iter on Anonymous View");
var anonViewIterRows = context.Db.PlayersAtLevelOne.Iter();
var expectedPlayerAndLevel = new PlayerAndLevel
{
Id = 1,
Identity = context.Identity!.Value,
Name = "NewPlayer",
Level = 1,
};
Log.Debug(
"PlayersAtLevelOne Iter count: "
+ (anonViewIterRows != null ? anonViewIterRows.Count().ToString() : "null")
);
Debug.Assert(anonViewIterRows != null && anonViewIterRows.Any());
Log.Debug(
"Validating Anonymous View row data "
+ $"Id={expectedPlayerAndLevel.Id}, Identity={expectedPlayerAndLevel.Identity}, Name={expectedPlayerAndLevel.Name}, Level={expectedPlayerAndLevel.Level} => "
+ $"Id={anonViewIterRows.First().Id}, Identity={anonViewIterRows.First().Identity}, Name={anonViewIterRows.First().Name}, Level={anonViewIterRows.First().Level} => "
);
Debug.Assert(anonViewIterRows.First().Equals(expectedPlayerAndLevel));
Log.Debug("Calling RemoteQuery on Anonymous View");
// If testing against Rust, the query will need to be changed to "WHERE level = 1"
var anonViewRemoteQueryRows = context.Db.PlayersAtLevelOne.RemoteQuery("WHERE Level = 1");
Log.Debug(
"PlayersAtLevelOne RemoteQuery count: "
+ (
anonViewRemoteQueryRows != null
? anonViewRemoteQueryRows.Result.Length.ToString()
: "null"
)
);
Debug.Assert(anonViewRemoteQueryRows != null && anonViewRemoteQueryRows.Result.Length > 0);
Debug.Assert(anonViewRemoteQueryRows.Result.First().Equals(expectedPlayerAndLevel));
Log.Debug("Calling SetNullableVec (null)");
waiting++;
context.Reducers.SetNullableVec(1, false, 0, 0);
Log.Debug("Calling SetNullableVec (some)");
waiting++;
context.Reducers.SetNullableVec(1, true, 7, 8);
Log.Debug("Calling InsertEmptyStringIntoNonNullable");
waiting++;
context.Reducers.InsertEmptyStringIntoNonNullable();
Log.Debug("Calling InsertNullStringIntoNonNullable (should fail)");
waiting++;
context.Reducers.InsertNullStringIntoNonNullable();
Log.Debug("Calling InsertNullStringIntoNullable");
waiting++;
context.Reducers.InsertNullStringIntoNullable();
// Procedures tests
Log.Debug("Calling ReadMySchemaViaHttp");
waiting++;
context.Procedures.ReadMySchemaViaHttp((IProcedureEventContext ctx, ProcedureCallbackResult<string> result) =>
{
try
{
Debug.Assert(result.IsSuccess, $"ReadMySchemaViaHttp should succeed. Error received: {result.Error}");
Debug.Assert(result.Value != null, "ReadMySchemaViaHttp should return a string");
Debug.Assert(result.Value.StartsWith("OK "), $"Expected OK prefix, got: {result.Value}");
Debug.Assert(
result.Value.Contains("example_data"),
$"Expected schema response to mention example_data, got: {result.Value}"
);
}
finally
{
waiting--;
}
});
Log.Debug("Calling InvalidHttpRequest");
waiting++;
context.Procedures.InvalidHttpRequest((IProcedureEventContext ctx, ProcedureCallbackResult<string> result) =>
{
try
{
Debug.Assert(result.IsSuccess, $"InvalidHttpRequest should succeed. Error received: {result.Error}");
Debug.Assert(result.Value != null, "InvalidHttpRequest should return a string");
Debug.Assert(result.Value.StartsWith("ERR "), $"Expected ERR prefix, got: {result.Value}");
}
finally
{
waiting--;
}
});
Log.Debug("Calling InsertWithTxRollback");
waiting++;
context.Procedures.InsertWithTxRollback((IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
{
if (result.IsSuccess)
{
Debug.Assert(context.Db.MyTable.Count == 0, $"MyTable should remain empty after rollback. Count was {context.Db.MyTable.Count}");
Log.Debug("Insert with transaction rollback succeeded");
}
else
{
throw new Exception("Expected InsertWithTransactionRollback to fail, but it succeeded");
}
waiting--;
});
Log.Debug("Calling InsertWithTxRollbackResult");
waiting++;
context.Procedures.InsertWithTxRollbackResult((IProcedureEventContext ctx, ProcedureCallbackResult<Result<ReturnStruct, string>> result) =>
{
if (result.IsSuccess)
{
Debug.Assert(context.Db.MyTable.Count == 0, $"MyTable should remain empty after rollback result. Count was {context.Db.MyTable.Count}");
Log.Debug("Insert with transaction result rollback succeeded");
}
else
{
throw new Exception("Expected InsertWithTxRollbackResult to fail, but it succeeded");
}
waiting--;
});
Log.Debug("Calling InsertWithTxPanic");
waiting++;
context.Procedures.InsertWithTxPanic((IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
{
try
{
Debug.Assert(result.IsSuccess, $"InsertWithTxPanic should succeed (exception is caught). Error received: {result.Error}");
Debug.Assert(context.Db.MyTable.Count == 0, $"MyTable should remain empty after exception abort. Count was {context.Db.MyTable.Count}");
}
finally
{
waiting--;
}
});
Log.Debug("Calling DanglingTxWarning");
waiting++;
context.Procedures.DanglingTxWarning((IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
{
try
{
Debug.Assert(result.IsSuccess, $"DanglingTxWarning should succeed. Error received: {result.Error}");
Debug.Assert(context.Db.MyTable.Count == 0, $"MyTable should remain empty after dangling tx auto-abort. Count was {context.Db.MyTable.Count}");
// Note: We can't easily assert on the warning log from client-side,
// but the server-side AssertRowCount verifies the auto-abort behavior
}
finally
{
waiting--;
}
});
Log.Debug("Calling InsertWithTxCommit");
waiting++;
context.Procedures.InsertWithTxCommit((IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
{
try
{
Debug.Assert(result.IsSuccess, $"InsertWithTxCommit should succeed. Error received: {result.Error}");
var expectedRow = new MyTable(new ReturnStruct(42, "magic"));
var row = context.Db.MyTable.Iter().FirstOrDefault();
Debug.Assert(row != null);
Debug.Assert(row.Equals(expectedRow));
Log.Debug("Insert with transaction commit succeeded");
}
finally
{
waiting--;
}
});
Log.Debug("Calling InsertWithTxRetry");
waiting++;
context.Procedures.InsertWithTxRetry((IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
{
try
{
Debug.Assert(result.IsSuccess, $"InsertWithTxRetry should succeed after retry. Error received: {result.Error}");
}
catch (Exception ex)
{
Log.Exception(ex);
throw;
}
finally
{
waiting--;
}
});
Log.Debug("Calling TxContextCapabilities");
waiting++;
context.Procedures.TxContextCapabilities((IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
{
try
{
Debug.Assert(result.IsSuccess, $"TxContextCapabilities should succeed. Error received: {result.Error}");
Debug.Assert(result.Value != null && result.Value.B.StartsWith("sender:"), $"Expected sender info, got {result.Value.B}");
// Verify the inserted row has the expected data
var rows = context.Db.MyTable.Iter().ToList();
var timestampRow = rows.FirstOrDefault(r => r.Field.B.StartsWith("tx-test"));
Debug.Assert(timestampRow != null && timestampRow.Field.A == 200, $"Expected field.A == 200, got {timestampRow.Field.A}");
Debug.Assert(timestampRow.Field.B == "tx-test", $"Expected field.B == 'tx-test', got {timestampRow.Field.B}");
}
finally
{
waiting--;
}
});
Log.Debug("Calling AuthenticationCapabilities");
waiting++;
context.Procedures.AuthenticationCapabilities((IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
{
try
{
Debug.Assert(result.IsSuccess, $"AuthenticationCapabilities should succeed. Error received: {result.Error}");
Debug.Assert(result.Value != null, "Should return a valid sender-derived value");
Debug.Assert(result.Value.B.Contains("jwt:") || result.Value.B == "no-jwt", $"Should return JWT info, got {result.Value.B}");
// Verify the inserted row has authentication information
var rows = context.Db.MyTable.Iter().ToList();
var authRow = rows.FirstOrDefault(r => r.Field.B.StartsWith("auth:"));
Debug.Assert(authRow is not null, "Should have a row with auth data");
Debug.Assert(authRow.Field.B.Contains("sender:"), "Auth row should contain sender info");
Debug.Assert(authRow.Field.B.Contains("conn:"), "Auth row should contain connection info");
}
finally
{
waiting--;
}
});
Log.Debug("Calling SubscriptionEventOffset");
waiting++;
context.Procedures.SubscriptionEventOffset((IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
{
try
{
Debug.Assert(result.IsSuccess, $"SubscriptionEventOffset should succeed. Error received: {result.Error}");
Debug.Assert(result.Value != null && result.Value.A == 999, $"Expected A == 999, got {result.Value.A}");
Debug.Assert(result.Value.B.StartsWith("committed:"), $"Expected committed timestamp, got {result.Value.B}");
// Verify the inserted row has the expected offset test data
var rows = context.Db.MyTable.Iter().ToList();
var offsetRow = rows.FirstOrDefault(r => r.Field.B.StartsWith("offset-test:"));
Debug.Assert(offsetRow is not null, "Should have a row with offset-test data");
Debug.Assert(offsetRow.Field.A == 999, "Offset test row should have A == 999");
// Note: Transaction offset information is not directly accessible in ProcedureEvent,
// but this test verifies that the transaction was committed and subscription events were generated
// The presence of the new row in the subscription confirms the transaction offset was processed
}
finally
{
waiting--;
}
});
Log.Debug("Calling DocumentationGapChecks with valid parameters");
waiting++;
context.Procedures.DocumentationGapChecks(42, "test-input", (IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
{
try
{
Debug.Assert(result.IsSuccess, "DocumentationGapChecks should succeed with valid parameters");
// Expected: inputValue * 2 + inputText.Length = 42 * 2 + 10 = 94
var expectedValue = 42u * 2 + (uint)"test-input".Length; // 84 + 10 = 94
Debug.Assert(result.Value != null && result.Value.A == expectedValue, $"Expected A == {expectedValue}, got {result.Value.A}");
Debug.Assert(result.Value.B.StartsWith("success:"), $"Expected success message, got {result.Value.B}");
Debug.Assert(result.Value.B.Contains("test-input"), "Result should contain input text");
// Verify the inserted row has the expected documentation gap test data
var rows = context.Db.MyTable.Iter().ToList();
var docGapRow = rows.FirstOrDefault(r => r.Field.B.StartsWith("doc-gap:"));
Debug.Assert(docGapRow is not null, "Should have a row with doc-gap data");
Debug.Assert(docGapRow.Field.A == expectedValue, $"Doc gap row should have A == {expectedValue}");
Debug.Assert(docGapRow.Field.B.Contains("test-input"), "Doc gap row should contain input text");
}
finally
{
waiting--;
}
});
// Test error handling with invalid parameters
Log.Debug("Calling DocumentationGapChecks with invalid parameters (should fail)");
waiting++;
context.Procedures.DocumentationGapChecks(0, "", (IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
{
try
{
Debug.Assert(!result.IsSuccess, "DocumentationGapChecks should fail with invalid parameters");
// TODO: Testing against Rust, this returned a different error type "System.Exception". Decide if this is a bug or not.
//Debug.Assert(result.Error is ArgumentException, $"Expected ArgumentException, got {result.Error?.GetType()}");
}
finally
{
waiting--;
}
});
// Now unsubscribe and check that the unsubscribing is actually applied.
Log.Debug("Calling Unsubscribe");
waiting++;
handle?.UnsubscribeThen(
(ctx) =>
{
Log.Debug("Received Unsubscribe");
ValidateBTreeIndexes(ctx);
waiting--;
}
);
}
System.AppDomain.CurrentDomain.UnhandledException += (sender, args) =>
{
Log.Exception($"Unhandled exception: {sender} {args}");
Environment.Exit(1);
};
var db = ConnectToDB();
Log.Info("Starting timer");
const int TIMEOUT = 20; // seconds;
var start = DateTime.Now;
while (!applied || waiting > 0)
{
db.FrameTick();
Thread.Sleep(100);
if ((DateTime.Now - start).Seconds > TIMEOUT)
{
Log.Error($"Timeout, all events should have elapsed in {TIMEOUT} seconds!");
Environment.Exit(1);
}
}
Log.Info("Success");
Environment.Exit(0);