mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-05-12 18:57:51 -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
831 lines
25 KiB
C#
831 lines
25 KiB
C#
// Server module for regression tests.
|
|
// Everything we're testing for happens SDK-side so this module is very uninteresting.
|
|
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using SpacetimeDB;
|
|
|
|
[SpacetimeDB.Type]
|
|
public partial class ReturnStruct
|
|
{
|
|
public uint A;
|
|
public string B;
|
|
|
|
public ReturnStruct(uint a, string b)
|
|
{
|
|
A = a;
|
|
B = b;
|
|
}
|
|
|
|
public ReturnStruct()
|
|
{
|
|
A = 0;
|
|
B = string.Empty;
|
|
}
|
|
}
|
|
|
|
[SpacetimeDB.Type]
|
|
public partial record ReturnEnum : SpacetimeDB.TaggedEnum<(
|
|
uint A,
|
|
string B
|
|
)>;
|
|
|
|
[SpacetimeDB.Type]
|
|
public partial struct DbVector2
|
|
{
|
|
public int X;
|
|
public int Y;
|
|
}
|
|
|
|
public static partial class Module
|
|
{
|
|
[SpacetimeDB.Table(Name = "my_table", Public = true)]
|
|
public partial struct MyTable
|
|
{
|
|
public ReturnStruct Field;
|
|
}
|
|
|
|
[SpacetimeDB.Table(Name = "where_test", Public = true)]
|
|
public partial struct WhereTest
|
|
{
|
|
[SpacetimeDB.PrimaryKey]
|
|
public uint Id;
|
|
|
|
[SpacetimeDB.Index.BTree]
|
|
public uint Value;
|
|
|
|
public string Name;
|
|
}
|
|
|
|
[SpacetimeDB.Table(Name = "example_data", Public = true)]
|
|
public partial struct ExampleData
|
|
{
|
|
[SpacetimeDB.PrimaryKey]
|
|
public uint Id;
|
|
|
|
[SpacetimeDB.Index.BTree]
|
|
public uint Indexed;
|
|
}
|
|
|
|
[SpacetimeDB.Table(Name = "my_log", Public = true)]
|
|
public partial struct MyLog
|
|
{
|
|
public Result<MyTable, string> msg;
|
|
}
|
|
|
|
[SpacetimeDB.Table(Name = "player", Public = true)]
|
|
public partial struct Player
|
|
{
|
|
[SpacetimeDB.PrimaryKey]
|
|
[SpacetimeDB.AutoInc]
|
|
public ulong Id;
|
|
|
|
[SpacetimeDB.Unique]
|
|
public Identity Identity;
|
|
|
|
public string Name;
|
|
}
|
|
|
|
[SpacetimeDB.Table(Name = "account", Public = true)]
|
|
public partial class Account
|
|
{
|
|
[SpacetimeDB.PrimaryKey]
|
|
[SpacetimeDB.AutoInc]
|
|
public ulong Id;
|
|
|
|
[SpacetimeDB.Unique]
|
|
public Identity Identity;
|
|
|
|
public string Name = "";
|
|
}
|
|
|
|
[SpacetimeDB.Table(Name = "player_level", Public = true)]
|
|
public partial struct PlayerLevel
|
|
{
|
|
[SpacetimeDB.Unique]
|
|
public ulong PlayerId;
|
|
|
|
[SpacetimeDB.Index.BTree]
|
|
public ulong Level;
|
|
}
|
|
|
|
[SpacetimeDB.Type]
|
|
public partial struct PlayerAndLevel
|
|
{
|
|
public ulong Id;
|
|
public Identity Identity;
|
|
public string Name;
|
|
public ulong Level;
|
|
}
|
|
|
|
[SpacetimeDB.Table(Name = "User", Public = true)]
|
|
public partial struct User
|
|
{
|
|
[SpacetimeDB.PrimaryKey]
|
|
public Uuid Id;
|
|
|
|
public string Name;
|
|
|
|
[SpacetimeDB.Index.BTree]
|
|
public bool IsAdmin;
|
|
}
|
|
|
|
[SpacetimeDB.Table(Name = "nullable_vec", Public = true)]
|
|
public partial struct NullableVec
|
|
{
|
|
[SpacetimeDB.PrimaryKey]
|
|
public uint Id;
|
|
|
|
public DbVector2? Pos;
|
|
}
|
|
|
|
[SpacetimeDB.Table(Name = "null_string_nonnullable", Public = true)]
|
|
public partial struct NullStringNonNullable
|
|
{
|
|
[SpacetimeDB.PrimaryKey]
|
|
[SpacetimeDB.AutoInc]
|
|
public ulong Id;
|
|
|
|
public string Name;
|
|
}
|
|
|
|
[SpacetimeDB.Table(Name = "null_string_nullable", Public = true)]
|
|
public partial struct NullStringNullable
|
|
{
|
|
[SpacetimeDB.PrimaryKey]
|
|
[SpacetimeDB.AutoInc]
|
|
public ulong Id;
|
|
|
|
public string? Name;
|
|
}
|
|
|
|
// At-most-one row: return T?
|
|
[SpacetimeDB.View(Name = "my_player", Public = true)]
|
|
public static Player? MyPlayer(ViewContext ctx)
|
|
{
|
|
return ctx.Db.player.Identity.Find(ctx.Sender);
|
|
}
|
|
|
|
[SpacetimeDB.View(Name = "my_account", Public = true)]
|
|
public static Account? MyAccount(ViewContext ctx)
|
|
{
|
|
return ctx.Db.account.Identity.Find(ctx.Sender) as Account;
|
|
}
|
|
|
|
[SpacetimeDB.View(Name = "my_account_missing", Public = true)]
|
|
public static Account? MyAccountMissing(ViewContext ctx)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Multiple rows: return a list
|
|
[SpacetimeDB.View(Name = "players_at_level_one", Public = true)]
|
|
public static List<PlayerAndLevel> PlayersAtLevelOne(AnonymousViewContext ctx)
|
|
{
|
|
var rows = new List<PlayerAndLevel>();
|
|
foreach (var player in ctx.Db.player_level.Level.Filter(1))
|
|
{
|
|
if (ctx.Db.player.Id.Find(player.PlayerId) is Player p)
|
|
{
|
|
var row = new PlayerAndLevel
|
|
{
|
|
Id = p.Id,
|
|
Identity = p.Identity,
|
|
Name = p.Name,
|
|
Level = player.Level
|
|
};
|
|
rows.Add(row);
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
[SpacetimeDB.View(Name = "Admins", Public = true)]
|
|
public static List<User> Admins(AnonymousViewContext ctx)
|
|
{
|
|
var rows = new List<User>();
|
|
foreach (var user in ctx.Db.User.IsAdmin.Filter(true))
|
|
{
|
|
rows.Add(user);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
[SpacetimeDB.View(Name = "nullable_vec_view", Public = true)]
|
|
public static List<NullableVec> NullableVecView(AnonymousViewContext ctx)
|
|
{
|
|
var rows = new List<NullableVec>();
|
|
|
|
if (ctx.Db.nullable_vec.Id.Find(1) is NullableVec row1)
|
|
{
|
|
rows.Add(row1);
|
|
}
|
|
|
|
if (ctx.Db.nullable_vec.Id.Find(2) is NullableVec row2)
|
|
{
|
|
rows.Add(row2);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
[SpacetimeDB.Reducer]
|
|
public static void Delete(ReducerContext ctx, uint id)
|
|
{
|
|
LogStopwatch sw = new("Delete");
|
|
ctx.Db.example_data.Id.Delete(id);
|
|
}
|
|
|
|
[SpacetimeDB.Reducer]
|
|
public static void Add(ReducerContext ctx, uint id, uint indexed)
|
|
{
|
|
ctx.Db.example_data.Insert(new ExampleData { Id = id, Indexed = indexed });
|
|
}
|
|
|
|
[SpacetimeDB.Reducer]
|
|
public static void ThrowError(ReducerContext ctx, string error)
|
|
{
|
|
throw new Exception(error);
|
|
}
|
|
|
|
[SpacetimeDB.Reducer]
|
|
public static void InsertResult(ReducerContext ctx, Result<MyTable, string> msg)
|
|
{
|
|
ctx.Db.my_log.Insert(new MyLog { msg = msg });
|
|
}
|
|
|
|
[SpacetimeDB.Reducer]
|
|
public static void SetNullableVec(ReducerContext ctx, uint id, bool hasPos, int x, int y)
|
|
{
|
|
var row = new NullableVec
|
|
{
|
|
Id = id,
|
|
Pos = hasPos ? new DbVector2 { X = x, Y = y } : null
|
|
};
|
|
|
|
if (ctx.Db.nullable_vec.Id.Find(id) is null)
|
|
{
|
|
ctx.Db.nullable_vec.Insert(row);
|
|
}
|
|
else
|
|
{
|
|
ctx.Db.nullable_vec.Id.Update(row);
|
|
}
|
|
}
|
|
|
|
[SpacetimeDB.Reducer]
|
|
public static void InsertEmptyStringIntoNonNullable(ReducerContext ctx)
|
|
{
|
|
ctx.Db.null_string_nonnullable.Insert(new NullStringNonNullable { Name = "" });
|
|
}
|
|
|
|
[SpacetimeDB.Reducer]
|
|
public static void InsertNullStringIntoNonNullable(ReducerContext ctx)
|
|
{
|
|
ctx.Db.null_string_nonnullable.Insert(new NullStringNonNullable { Name = null! });
|
|
}
|
|
|
|
[SpacetimeDB.Reducer]
|
|
public static void InsertNullStringIntoNullable(ReducerContext ctx)
|
|
{
|
|
ctx.Db.null_string_nullable.Insert(new NullStringNullable { Name = null });
|
|
}
|
|
|
|
[SpacetimeDB.Reducer]
|
|
public static void InsertWhereTest(ReducerContext ctx, uint id, uint value, string name)
|
|
{
|
|
ctx.Db.where_test.Insert(new WhereTest { Id = id, Value = value, Name = name });
|
|
}
|
|
|
|
[Reducer(ReducerKind.ClientConnected)]
|
|
public static void ClientConnected(ReducerContext ctx)
|
|
{
|
|
Log.Info($"Connect {ctx.Sender}");
|
|
|
|
if (ctx.Db.player.Identity.Find(ctx.Sender) is Player player)
|
|
{
|
|
// We are not logging player login status, so do nothing
|
|
}
|
|
else
|
|
{
|
|
// Lets setup a new player with a level of 1
|
|
ctx.Db.player.Insert(new Player { Identity = ctx.Sender, Name = "NewPlayer" });
|
|
var playerId = (ctx.Db.player.Identity.Find(ctx.Sender)!).Value.Id;
|
|
ctx.Db.player_level.Insert(new PlayerLevel { PlayerId = playerId, Level = 1 });
|
|
}
|
|
|
|
if (ctx.Db.account.Identity.Find(ctx.Sender) is null)
|
|
{
|
|
ctx.Db.account.Insert(new Account { Identity = ctx.Sender, Name = "Account" });
|
|
}
|
|
|
|
if (ctx.Db.nullable_vec.Id.Find(1) is null)
|
|
{
|
|
ctx.Db.nullable_vec.Insert(new NullableVec
|
|
{
|
|
Id = 1,
|
|
Pos = new DbVector2 { X = 1, Y = 2 },
|
|
});
|
|
}
|
|
|
|
if (ctx.Db.nullable_vec.Id.Find(2) is null)
|
|
{
|
|
ctx.Db.nullable_vec.Insert(new NullableVec
|
|
{
|
|
Id = 2,
|
|
Pos = null,
|
|
});
|
|
}
|
|
|
|
foreach (var (Name, IsAdmin) in new List<(string Name, bool IsAdmin)>
|
|
{
|
|
("Alice", true),
|
|
("Bob", false),
|
|
("Charlie", true)
|
|
})
|
|
{
|
|
ctx.Db.User.Insert(new User { Id = ctx.NewUuidV7(), Name = Name, IsAdmin = IsAdmin });
|
|
}
|
|
|
|
if (ctx.Db.where_test.Id.Find(1) is null)
|
|
{
|
|
ctx.Db.where_test.Insert(new WhereTest { Id = 1, Value = 5, Name = "low" });
|
|
}
|
|
if (ctx.Db.where_test.Id.Find(2) is null)
|
|
{
|
|
ctx.Db.where_test.Insert(new WhereTest { Id = 2, Value = 15, Name = "high" });
|
|
}
|
|
if (ctx.Db.where_test.Id.Find(3) is null)
|
|
{
|
|
ctx.Db.where_test.Insert(new WhereTest { Id = 3, Value = 15, Name = "alsohigh" });
|
|
}
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static uint ReturnPrimitive(ProcedureContext ctx, uint lhs, uint rhs)
|
|
{
|
|
return lhs + rhs;
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static ReturnStruct ReturnStructProcedure(ProcedureContext ctx, uint a, string b)
|
|
{
|
|
return new ReturnStruct(a, b);
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static ReturnEnum ReturnEnumA(ProcedureContext ctx, uint a)
|
|
{
|
|
return new ReturnEnum.A(a);
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static ReturnEnum ReturnEnumB(ProcedureContext ctx, string b)
|
|
{
|
|
return new ReturnEnum.B(b);
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static Uuid ReturnUuid(ProcedureContext ctx, Uuid u)
|
|
{
|
|
return u;
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static SpacetimeDB.Unit WillPanic(ProcedureContext ctx)
|
|
{
|
|
throw new InvalidOperationException("This procedure is expected to panic");
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
[Experimental("STDB_UNSTABLE")]
|
|
public static string ReadMySchemaViaHttp(ProcedureContext ctx)
|
|
{
|
|
try
|
|
{
|
|
var moduleIdentity = ProcedureContext.Identity;
|
|
var uri = $"http://localhost:3000/v1/database/{moduleIdentity}/schema?version=9";
|
|
var res = ctx.Http.Get(uri, System.TimeSpan.FromSeconds(2));
|
|
return res switch
|
|
{
|
|
Result<HttpResponse, HttpError>.OkR(var v) => "OK " + v.Body.ToStringUtf8Lossy(),
|
|
Result<HttpResponse, HttpError>.ErrR(var e) => "ERR " + e.Message,
|
|
_ => throw new InvalidOperationException("Unknown Result variant."),
|
|
};
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return "EXN " + e;
|
|
}
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
[Experimental("STDB_UNSTABLE")]
|
|
public static string InvalidHttpRequest(ProcedureContext ctx)
|
|
{
|
|
try
|
|
{
|
|
var res = ctx.Http.Get("http://foo.invalid/", System.TimeSpan.FromMilliseconds(250));
|
|
return res switch
|
|
{
|
|
Result<HttpResponse, HttpError>.OkR(var v) => "OK " + v.Body.ToStringUtf8Lossy(),
|
|
Result<HttpResponse, HttpError>.ErrR(var e) => "ERR " + e.Message,
|
|
_ => throw new InvalidOperationException("Unknown Result variant."),
|
|
};
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
return "EXN " + e;
|
|
}
|
|
}
|
|
|
|
#pragma warning disable STDB_UNSTABLE
|
|
[SpacetimeDB.Procedure]
|
|
public static void InsertWithTxCommit(ProcedureContext ctx)
|
|
{
|
|
ctx.WithTx(tx =>
|
|
{
|
|
tx.Db.my_table.Insert(new MyTable
|
|
{
|
|
Field = new ReturnStruct(a: 42, b: "magic"),
|
|
});
|
|
return new Unit();
|
|
});
|
|
|
|
AssertRowCount(ctx, 1);
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static void InsertWithTxRollback(ProcedureContext ctx)
|
|
{
|
|
var outcome = ctx.TryWithTx<SpacetimeDB.Unit, InvalidOperationException>(tx =>
|
|
{
|
|
tx.Db.my_table.Insert(new MyTable
|
|
{
|
|
Field = new ReturnStruct(a: 42, b: "magic")
|
|
});
|
|
|
|
throw new InvalidOperationException("rollback");
|
|
});
|
|
|
|
Debug.Assert(!outcome.IsSuccess, "TryWithTxAsync should report failure");
|
|
AssertRowCount(ctx, 0);
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static Result<ReturnStruct, string> InsertWithTxRollbackResult(ProcedureContext ctx)
|
|
{
|
|
try
|
|
{
|
|
var outcome = ctx.TryWithTx<SpacetimeDB.Unit, InvalidOperationException>(tx =>
|
|
{
|
|
tx.Db.my_table.Insert(new MyTable
|
|
{
|
|
Field = new ReturnStruct(a: 42, b: "magic")
|
|
});
|
|
|
|
throw new InvalidOperationException("rollback");
|
|
});
|
|
Debug.Assert(!outcome.IsSuccess, "TryWithTxAsync should report failure");
|
|
AssertRowCount(ctx, 0);
|
|
return Result<ReturnStruct, string>.Ok(new ReturnStruct(a: 42, b: "magic"));
|
|
}
|
|
catch (System.Exception e)
|
|
{
|
|
return Result<ReturnStruct, string>.Err(e.ToString());
|
|
}
|
|
}
|
|
|
|
private static void AssertRowCount(ProcedureContext ctx, ulong expected)
|
|
{
|
|
ctx.WithTx(tx =>
|
|
{
|
|
var actual = tx.Db.my_table.Count;
|
|
if (actual != expected)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Expected {expected} MyTable rows but found {actual}."
|
|
);
|
|
}
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
[SpacetimeDB.Table(Name = "retry_log", Public = true)]
|
|
public partial class RetryLog
|
|
{
|
|
[SpacetimeDB.PrimaryKey]
|
|
public uint Id;
|
|
public uint Attempts;
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static void InsertWithTxRetry(ProcedureContext ctx)
|
|
{
|
|
const uint key = 1;
|
|
|
|
var outcome = ctx.TryWithTx<uint, Exception>(tx =>
|
|
{
|
|
var existing = tx.Db.retry_log.Id.Find(key);
|
|
|
|
if (existing is null)
|
|
{
|
|
tx.Db.retry_log.Insert(new RetryLog { Id = key, Attempts = 1 });
|
|
return Result<uint, Exception>.Err(new Exception("conflict"));
|
|
}
|
|
|
|
// Use the unique index Update method
|
|
var newAttempts = existing.Attempts + 1;
|
|
tx.Db.retry_log.Id.Update(new RetryLog { Id = key, Attempts = newAttempts });
|
|
return Result<uint, Exception>.Ok(newAttempts);
|
|
});
|
|
|
|
if (!outcome.IsSuccess)
|
|
{
|
|
outcome = ctx.TryWithTx<uint, Exception>(tx =>
|
|
{
|
|
var existing = tx.Db.retry_log.Id.Find(key);
|
|
|
|
if (existing is null)
|
|
{
|
|
tx.Db.retry_log.Insert(new RetryLog { Id = key, Attempts = 1 });
|
|
return Result<uint, Exception>.Err(new Exception("conflict"));
|
|
}
|
|
|
|
// Use the unique index Update method
|
|
var newAttempts = existing.Attempts + 1;
|
|
tx.Db.retry_log.Id.Update(new RetryLog { Id = key, Attempts = newAttempts });
|
|
return Result<uint, Exception>.Ok(newAttempts);
|
|
});
|
|
}
|
|
|
|
Debug.Assert(outcome.IsSuccess, "Retry should have succeeded");
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static void InsertWithTxPanic(ProcedureContext ctx)
|
|
{
|
|
try
|
|
{
|
|
ctx.WithTx<object>(tx =>
|
|
{
|
|
// Insert a row
|
|
tx.Db.my_table.Insert(new MyTable
|
|
{
|
|
Field = new ReturnStruct(a: 99, b: "panic-test")
|
|
});
|
|
|
|
// Throw an exception to abort the transaction
|
|
throw new InvalidOperationException("panic abort");
|
|
});
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message == "panic abort")
|
|
{
|
|
// Expected exception - transaction should be aborted
|
|
}
|
|
|
|
// Verify no rows were inserted due to the exception
|
|
AssertRowCount(ctx, 0);
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static void DanglingTxWarning(ProcedureContext ctx)
|
|
{
|
|
// This test demonstrates transaction cleanup when an unhandled exception occurs
|
|
// during transaction processing, which should trigger auto-abort behavior
|
|
|
|
var exceptionCaught = false;
|
|
|
|
try
|
|
{
|
|
ctx.WithTx<object>(tx =>
|
|
{
|
|
// Insert a row
|
|
tx.Db.my_table.Insert(new MyTable
|
|
{
|
|
Field = new ReturnStruct(a: 123, b: "dangling")
|
|
});
|
|
|
|
// Simulate an unexpected system exception that might leave transaction in limbo
|
|
// This should trigger the transaction cleanup/auto-abort mechanisms
|
|
throw new SystemException("Simulated system failure during transaction");
|
|
});
|
|
}
|
|
catch (SystemException)
|
|
{
|
|
exceptionCaught = true;
|
|
}
|
|
|
|
// Verify the exception was caught and no rows were persisted
|
|
if (!exceptionCaught)
|
|
{
|
|
throw new InvalidOperationException("Expected SystemException was not thrown");
|
|
}
|
|
|
|
// Verify no rows were persisted due to transaction abort
|
|
AssertRowCount(ctx, 0);
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static ReturnStruct TxContextCapabilities(ProcedureContext ctx)
|
|
{
|
|
var result = ctx.WithTx(tx =>
|
|
{
|
|
// Test 1: Verify transaction context has database access
|
|
var initialCount = tx.Db.my_table.Count;
|
|
|
|
// Test 2: Insert data and verify it's visible within the same transaction
|
|
tx.Db.my_table.Insert(new MyTable
|
|
{
|
|
Field = new ReturnStruct(a: 200, b: "tx-test")
|
|
});
|
|
|
|
var countAfterInsert = tx.Db.my_table.Count;
|
|
if (countAfterInsert != initialCount + 1)
|
|
{
|
|
throw new InvalidOperationException($"Expected count {initialCount + 1}, got {countAfterInsert}");
|
|
}
|
|
|
|
// Test 3: Verify transaction context properties are accessible
|
|
var txSender = tx.Sender;
|
|
var txTimestamp = tx.Timestamp;
|
|
|
|
if (txSender.Equals(ctx.Sender) == false)
|
|
{
|
|
throw new InvalidOperationException("Transaction sender should match procedure sender");
|
|
}
|
|
|
|
// Test 4: Return data from within transaction
|
|
return new ReturnStruct(a: (uint)countAfterInsert, b: $"sender:{txSender}");
|
|
});
|
|
|
|
// Verify the row was committed - use flexible row count check
|
|
try
|
|
{
|
|
ctx.WithTx(tx =>
|
|
{
|
|
var actualCount = tx.Db.my_table.Count;
|
|
if (actualCount == 0)
|
|
{
|
|
throw new InvalidOperationException("Expected at least 1 MyTable row but found none - transaction may not have committed");
|
|
}
|
|
return 0;
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Log the assertion failure but don't fail the procedure
|
|
Log.Error($"TxContextCapabilities row count assertion failed: {ex.Message}");
|
|
// Still return the valid result from the transaction
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static ReturnStruct AuthenticationCapabilities(ProcedureContext ctx)
|
|
{
|
|
// Test 1: Verify authentication context is accessible from procedure context
|
|
var procAuth = ctx.SenderAuth;
|
|
var procSender = ctx.Sender;
|
|
var procConnectionId = ctx.ConnectionId;
|
|
|
|
var result = ctx.WithTx(tx =>
|
|
{
|
|
// Test 2: Verify authentication context is accessible from transaction context
|
|
var txAuth = tx.SenderAuth;
|
|
var txSender = tx.Sender;
|
|
var txConnectionId = tx.ConnectionId;
|
|
|
|
// Test 3: Authentication contexts should be consistent
|
|
if (txSender.Equals(procSender) == false)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Transaction sender {txSender} should match procedure sender {procSender}");
|
|
}
|
|
|
|
if (txConnectionId.Equals(procConnectionId) == false)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Transaction connectionId {txConnectionId} should match procedure connectionId {procConnectionId}");
|
|
}
|
|
|
|
// Test 4: Insert data with authentication information
|
|
tx.Db.my_table.Insert(new MyTable
|
|
{
|
|
Field = new ReturnStruct(
|
|
a: (uint)(txSender.GetHashCode() & 0xFF),
|
|
b: $"auth:sender:{txSender}:conn:{txConnectionId}")
|
|
});
|
|
|
|
// Test 5: Check JWT claims (if available)
|
|
var jwtInfo = "no-jwt";
|
|
try
|
|
{
|
|
var jwt = txAuth.Jwt;
|
|
if (jwt != null)
|
|
{
|
|
jwtInfo = $"jwt:present:identity:{jwt.Identity}";
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// JWT may not be available in test environment
|
|
jwtInfo = "jwt:unavailable";
|
|
}
|
|
|
|
return new ReturnStruct(
|
|
a: (uint)(txSender.GetHashCode() & 0xFF),
|
|
b: jwtInfo);
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static ReturnStruct SubscriptionEventOffset(ProcedureContext ctx)
|
|
{
|
|
// This procedure tests that subscription events carry transaction offset information
|
|
// We'll insert data and return information that helps verify the transaction offset
|
|
|
|
var result = ctx.WithTx(tx =>
|
|
{
|
|
// Insert a row that will trigger subscription events
|
|
var testData = new MyTable
|
|
{
|
|
Field = new ReturnStruct(
|
|
a: 999, // Use a distinctive value to identify this test
|
|
b: $"offset-test:{tx.Timestamp.MicrosecondsSinceUnixEpoch}")
|
|
};
|
|
|
|
tx.Db.my_table.Insert(testData);
|
|
|
|
// Return data that can be used to correlate with subscription events
|
|
return new ReturnStruct(
|
|
a: 999,
|
|
b: $"committed:{tx.Timestamp.MicrosecondsSinceUnixEpoch}");
|
|
});
|
|
|
|
// At this point, the transaction should be committed and subscription events
|
|
// should be generated with the transaction offset information
|
|
|
|
return result;
|
|
}
|
|
|
|
[SpacetimeDB.Procedure]
|
|
public static ReturnStruct DocumentationGapChecks(ProcedureContext ctx, uint inputValue, string inputText)
|
|
{
|
|
// This procedure tests various documentation gaps and edge cases
|
|
// Test 1: Parameter handling - procedures can accept multiple parameters
|
|
if (inputValue == 0)
|
|
{
|
|
throw new ArgumentException("inputValue cannot be zero");
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(inputText))
|
|
{
|
|
throw new ArgumentException("inputText cannot be null or empty");
|
|
}
|
|
|
|
var result = ctx.WithTx(tx =>
|
|
{
|
|
// Test 2: Multiple database operations in single transaction
|
|
var count = tx.Db.my_table.Count;
|
|
|
|
// Test 3: Conditional logic based on database state
|
|
if (count > 10)
|
|
{
|
|
// Don't insert if too many rows
|
|
return new ReturnStruct(
|
|
a: (uint)count,
|
|
b: $"skipped:too-many-rows:{count}");
|
|
}
|
|
|
|
// Test 4: Complex data manipulation
|
|
var processedValue = inputValue * 2 + (uint)inputText.Length;
|
|
|
|
tx.Db.my_table.Insert(new MyTable
|
|
{
|
|
Field = new ReturnStruct(
|
|
a: processedValue,
|
|
b: $"doc-gap:{inputText}:processed:{processedValue}")
|
|
});
|
|
|
|
// Test 5: Return computed results
|
|
return new ReturnStruct(
|
|
a: processedValue,
|
|
b: $"success:input:{inputText}:result:{processedValue}");
|
|
});
|
|
|
|
// Test 6: Post-transaction validation
|
|
var finalCount = ctx.WithTx(tx => tx.Db.my_table.Count);
|
|
|
|
if (finalCount <= 0)
|
|
{
|
|
throw new InvalidOperationException("Expected at least one row after transaction");
|
|
}
|
|
|
|
return result;
|
|
}
|
|
#pragma warning restore STDB_UNSTABLE
|
|
}
|