mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-05-13 03:08:40 -04:00
4f0a21f948
# 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
747 lines
29 KiB
C#
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);
|