From f3152900366400bd83ac667c72f22ef461effd42 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Wed, 11 Mar 2026 10:00:44 -0700 Subject: [PATCH 1/8] [C#] Primary keys for query builder views --- .../server/snapshots/Module#FFI.verified.cs | 12 +- crates/bindings-csharp/Codegen/Module.cs | 27 +- crates/codegen/src/csharp.rs | 7 + modules/sdk-test-view-pk-cs/.gitignore | 2 + modules/sdk-test-view-pk-cs/Lib.cs | 92 +++ modules/sdk-test-view-pk-cs/README.md | 8 + .../sdk-test-view-pk-cs.csproj | 13 + .../view-pk-client/Program.cs | 483 +++++++++++++ .../view-pk-client/client.csproj | 15 + .../Reducers/InsertViewPkMembership.g.cs | 73 ++ .../InsertViewPkMembershipSecondary.g.cs | 73 ++ .../Reducers/InsertViewPkPlayer.g.cs | 74 ++ .../Reducers/UpdateViewPkPlayer.g.cs | 74 ++ .../module_bindings/SpacetimeDBClient.g.cs | 646 ++++++++++++++++++ .../Tables/AllViewPkPlayers.g.cs | 51 ++ .../Tables/SenderViewPkPlayersA.g.cs | 51 ++ .../Tables/SenderViewPkPlayersB.g.cs | 51 ++ .../Tables/ViewPkMembership.g.cs | 73 ++ .../Tables/ViewPkMembershipSecondary.g.cs | 73 ++ .../module_bindings/Tables/ViewPkPlayer.g.cs | 61 ++ .../Types/ViewPkMembership.g.cs | 34 + .../Types/ViewPkMembershipSecondary.g.cs | 34 + .../module_bindings/Types/ViewPkPlayer.g.cs | 35 + sdks/csharp/tools~/gen-regression-tests.sh | 1 + sdks/rust/tests/test.rs | 1 + 25 files changed, 2049 insertions(+), 15 deletions(-) create mode 100644 modules/sdk-test-view-pk-cs/.gitignore create mode 100644 modules/sdk-test-view-pk-cs/Lib.cs create mode 100644 modules/sdk-test-view-pk-cs/README.md create mode 100644 modules/sdk-test-view-pk-cs/sdk-test-view-pk-cs.csproj create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/Program.cs create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/client.csproj create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkMembership.g.cs create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkMembershipSecondary.g.cs create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkPlayer.g.cs create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/UpdateViewPkPlayer.g.cs create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/SpacetimeDBClient.g.cs create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/AllViewPkPlayers.g.cs create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/SenderViewPkPlayersA.g.cs create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/SenderViewPkPlayersB.g.cs create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkMembership.g.cs create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkMembershipSecondary.g.cs create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkPlayer.g.cs create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkMembership.g.cs create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkMembershipSecondary.g.cs create mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkPlayer.g.cs diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs index c519fb9d7e0..9bbe0b3ddc0 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs @@ -1636,10 +1636,14 @@ SpacetimeDB.BSATN.ITypeRegistrar registrar IsPublic: true, IsAnonymous: false, Params: [], - ReturnType: new SpacetimeDB.BSATN.ValueOption< - PublicTable, - PublicTable.BSATN - >().GetAlgebraicType(registrar) + ReturnType: new global::SpacetimeDB.BSATN.AlgebraicType.Product( + [ + new global::SpacetimeDB.BSATN.AggregateElement( + "__query__", + new PublicTable.BSATN().GetAlgebraicType(registrar) + ) + ] + ) ); public byte[] Invoke( diff --git a/crates/bindings-csharp/Codegen/Module.cs b/crates/bindings-csharp/Codegen/Module.cs index e9c0d0522e5..0d555e7548e 100644 --- a/crates/bindings-csharp/Codegen/Module.cs +++ b/crates/bindings-csharp/Codegen/Module.cs @@ -1122,6 +1122,7 @@ record ViewDeclaration public readonly bool IsPublic; public readonly bool ReturnsQuery; public readonly TypeUse ReturnType; + public readonly TypeUse? QueryRowType; public readonly EquatableArray Parameters; public readonly Scope Scope; @@ -1186,15 +1187,12 @@ method.ReturnType is INamedTypeSymbol { ReturnsQuery = true; var rowType = TypeUse.Parse(method, queryRowType, diag); - var optType = queryRowType.IsValueType - ? "SpacetimeDB.BSATN.ValueOption" - : "SpacetimeDB.BSATN.RefOption"; - var opt = $"{optType}<{rowType.Name}, {rowType.BSATNName}>"; - // Match Rust semantics: Query is described as a nullable row (T?). - ReturnType = new ReferenceUse(opt, opt); + QueryRowType = rowType; + ReturnType = rowType; } else { + QueryRowType = null; ReturnType = TypeUse.Parse(method, method.ReturnType, diag); } Scope = new Scope(methodSyntax.Parent as MemberDeclarationSyntax); @@ -1211,9 +1209,10 @@ method.ReturnType is INamedTypeSymbol diag.Report(ErrorDescriptor.ViewContextParam, methodSyntax); } - // Validate return type: must be List or T? + // Validate return type: must be List, T?, or IQuery. if ( - !ReturnType.BSATNName.Contains("SpacetimeDB.BSATN.ValueOption") + !ReturnsQuery + && !ReturnType.BSATNName.Contains("SpacetimeDB.BSATN.ValueOption") && !ReturnType.BSATNName.Contains("SpacetimeDB.BSATN.RefOption") && !ReturnType.BSATNName.Contains("SpacetimeDB.BSATN.List") ) @@ -1229,17 +1228,23 @@ method.ReturnType is INamedTypeSymbol ); } - public string GenerateViewDef(uint Index) => - $$$""" + public string GenerateViewDef(uint Index) + { + var returnTypeExpr = + ReturnsQuery + ? $"new global::SpacetimeDB.BSATN.AlgebraicType.Product([new global::SpacetimeDB.BSATN.AggregateElement(\"__query__\", new global::SpacetimeDB.BSATN.AlgebraicType.Ref(new {QueryRowType!.BSATNName}().GetAlgebraicType(registrar).Ref_))])" + : $"new {ReturnType.BSATNName}().GetAlgebraicType(registrar)"; + return $$$""" new global::SpacetimeDB.Internal.RawViewDefV10( SourceName: "{{{Name}}}", Index: {{{Index}}}, IsPublic: {{{IsPublic.ToString().ToLower()}}}, IsAnonymous: {{{IsAnonymous.ToString().ToLower()}}}, Params: [{{{MemberDeclaration.GenerateDefs(Parameters)}}}], - ReturnType: new {{{ReturnType.BSATNName}}}().GetAlgebraicType(registrar) + ReturnType: {{{returnTypeExpr}}} ); """; + } /// /// Generates the class responsible for evaluating a view. diff --git a/crates/codegen/src/csharp.rs b/crates/codegen/src/csharp.rs index e449f703181..76907815898 100644 --- a/crates/codegen/src/csharp.rs +++ b/crates/codegen/src/csharp.rs @@ -653,6 +653,13 @@ impl Lang for Csharp<'_> { } } } + for (columns, constraints) in schema.backcompat_column_constraints() { + if constraints.has_indexed() || constraints.has_unique() || constraints.has_primary_key() { + for col_pos in columns.iter() { + ix_col_positions.insert(col_pos.idx()); + } + } + } writeln!(output, "public sealed class {cols_owner_name}Cols"); indented_block(&mut output, |output| { diff --git a/modules/sdk-test-view-pk-cs/.gitignore b/modules/sdk-test-view-pk-cs/.gitignore new file mode 100644 index 00000000000..1746e3269ed --- /dev/null +++ b/modules/sdk-test-view-pk-cs/.gitignore @@ -0,0 +1,2 @@ +bin +obj diff --git a/modules/sdk-test-view-pk-cs/Lib.cs b/modules/sdk-test-view-pk-cs/Lib.cs new file mode 100644 index 00000000000..5fa58de076b --- /dev/null +++ b/modules/sdk-test-view-pk-cs/Lib.cs @@ -0,0 +1,92 @@ +namespace SpacetimeDB.Sdk.Test.ViewPk; + +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.Table(Accessor = "view_pk_player", Public = true)] + public partial struct ViewPkPlayer + { + [SpacetimeDB.PrimaryKey] + public ulong id; + public string name; + } + + [SpacetimeDB.Table(Accessor = "view_pk_membership", Public = true)] + public partial struct ViewPkMembership + { + [SpacetimeDB.PrimaryKey] + public ulong id; + + [SpacetimeDB.Index.BTree] + public ulong player_id; + } + + [SpacetimeDB.Table(Accessor = "view_pk_membership_secondary", Public = true)] + public partial struct ViewPkMembershipSecondary + { + [SpacetimeDB.PrimaryKey] + public ulong id; + + [SpacetimeDB.Index.BTree] + public ulong player_id; + } + + [SpacetimeDB.Reducer] + public static void insert_view_pk_player(ReducerContext ctx, ulong id, string name) + { + ctx.Db.view_pk_player.Insert(new ViewPkPlayer { id = id, name = name }); + } + + [SpacetimeDB.Reducer] + public static void update_view_pk_player(ReducerContext ctx, ulong id, string name) + { + ctx.Db.view_pk_player.id.Update(new ViewPkPlayer { id = id, name = name }); + } + + [SpacetimeDB.Reducer] + public static void insert_view_pk_membership(ReducerContext ctx, ulong id, ulong player_id) + { + ctx.Db.view_pk_membership.Insert(new ViewPkMembership { id = id, player_id = player_id }); + } + + [SpacetimeDB.Reducer] + public static void insert_view_pk_membership_secondary( + ReducerContext ctx, + ulong id, + ulong player_id + ) + { + ctx.Db.view_pk_membership_secondary.Insert( + new ViewPkMembershipSecondary { id = id, player_id = player_id } + ); + } + + [SpacetimeDB.View(Accessor = "all_view_pk_players", Public = true)] + public static IQuery all_view_pk_players(ViewContext ctx) + { + return ctx.From.view_pk_player(); + } + + [SpacetimeDB.View(Accessor = "sender_view_pk_players_a", Public = true)] + public static IQuery sender_view_pk_players_a(ViewContext ctx) + { + return ctx + .From.view_pk_membership() + .RightSemijoin( + ctx.From.view_pk_player(), + (membership, player) => membership.player_id.Eq(player.id) + ); + } + + [SpacetimeDB.View(Accessor = "sender_view_pk_players_b", Public = true)] + public static IQuery sender_view_pk_players_b(ViewContext ctx) + { + return ctx + .From.view_pk_membership_secondary() + .RightSemijoin( + ctx.From.view_pk_player(), + (membership, player) => membership.player_id.Eq(player.id) + ); + } +} diff --git a/modules/sdk-test-view-pk-cs/README.md b/modules/sdk-test-view-pk-cs/README.md new file mode 100644 index 00000000000..a62bd410bfe --- /dev/null +++ b/modules/sdk-test-view-pk-cs/README.md @@ -0,0 +1,8 @@ +# `sdk-test-view-pk-cs` *C#* test + +See the [sdk-test-view-pk README](../sdk-test-view-pk/README.md) for more details. + +> **WARNING**: This C# source code is manually derived from `../sdk-test-view-pk/src/lib.rs` +> and is supposed to be functionally equivalent. +> Do not add new types or functionality here that are not present in the *Rust* version, +> because they're compared against each other. diff --git a/modules/sdk-test-view-pk-cs/sdk-test-view-pk-cs.csproj b/modules/sdk-test-view-pk-cs/sdk-test-view-pk-cs.csproj new file mode 100644 index 00000000000..d4caede5ffa --- /dev/null +++ b/modules/sdk-test-view-pk-cs/sdk-test-view-pk-cs.csproj @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/Program.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/Program.cs new file mode 100644 index 00000000000..04521c89439 --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/Program.cs @@ -0,0 +1,483 @@ +/// View-PK regression tests run with a live server. +/// To run these, start a local SpacetimeDB via `spacetime start`, +/// publish `modules/sdk-test-view-pk-cs`, and then run this client. +using System.Threading; +using SpacetimeDB; +using SpacetimeDB.Types; + +const string HOST = "http://localhost:3000"; +const string DBNAME = "view-pk-tests"; +const int TIMEOUT_SECONDS = 20; + +long idCounter = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 10; +ulong NextId() => (ulong)Interlocked.Increment(ref idCounter); + +var tests = new Dictionary(StringComparer.Ordinal) +{ + ["view-pk-on-update"] = ExecViewPkOnUpdate, + ["view-pk-join-query-builder"] = ExecViewPkJoinQueryBuilder, + ["view-pk-semijoin-two-sender-views-query-builder"] = + ExecViewPkSemijoinTwoSenderViewsQueryBuilder, +}; + +if (args.Length > 1) +{ + throw new ArgumentException("Pass zero args (run all) or a single test name."); +} + +System.AppDomain.CurrentDomain.UnhandledException += (sender, evt) => +{ + Log.Exception($"Unhandled exception: {sender} {evt}"); + Environment.Exit(1); +}; + +if (args.Length == 1) +{ + var testName = args[0]; + if (!tests.TryGetValue(testName, out var test)) + { + throw new ArgumentException($"Unknown test: {testName}"); + } + + Log.Info($"Running {testName}"); + test(); +} +else +{ + foreach (var (testName, test) in tests) + { + Log.Info($"Running {testName}"); + test(); + } +} + +Log.Info("Success"); +Environment.Exit(0); + +void Expect(bool condition, string message) +{ + if (!condition) + { + throw new Exception(message); + } +} + +void AssertReducerCommitted(string reducerName, ReducerEventContext ctx) +{ + switch (ctx.Event.Status) + { + case Status.Committed: + return; + case Status.Failed(var reason): + throw new Exception($"`{reducerName}` reducer returned error: {reason}"); + case Status.OutOfEnergy(var _): + throw new Exception($"`{reducerName}` reducer ran out of energy"); + default: + throw new Exception($"`{reducerName}` reducer returned unexpected status: {ctx.Event.Status}"); + } +} + +void RunViewPkTest( + string testName, + Action> start +) +{ + bool complete = false; + bool disconnectExpected = false; + Exception? failure = null; + + void Pass() + { + complete = true; + } + + void Fail(Exception error) + { + failure ??= error; + } + + var conn = DbConnection + .Builder() + .WithUri(HOST) + .WithDatabaseName(DBNAME) + .OnConnect((connected, _, _) => + { + try + { + start(connected, Pass, Fail); + } + catch (Exception ex) + { + Fail(ex); + } + }) + .OnConnectError(err => + { + Fail(err); + }) + .OnDisconnect((_, err) => + { + if (disconnectExpected) + { + return; + } + + if (err != null) + { + Fail(err); + return; + } + + if (!complete) + { + Fail(new Exception($"Unexpected disconnect in {testName}")); + } + }) + .Build(); + + var deadline = DateTime.UtcNow.AddSeconds(TIMEOUT_SECONDS); + while (!complete && failure == null) + { + conn.FrameTick(); + Thread.Sleep(10); + + if (DateTime.UtcNow > deadline) + { + throw new TimeoutException($"Timeout waiting for {testName}"); + } + } + + disconnectExpected = true; + if (conn.IsActive) + { + conn.Disconnect(); + } + + if (failure != null) + { + throw new Exception($"{testName} failed", failure); + } +} + +/// Subscribe to a query builder view whose underlying table has a primary key. +/// Ensures the C# SDK emits an `OnUpdate` callback and that the client receives the correct old and new rows. +/// +/// Test: +/// 1. Subscribe to: SELECT * FROM all_view_pk_players +/// 2. Insert row: (id=1, name="before") +/// 3. Update row: (id=1, name="after") +/// +/// Expect: +/// - `OnUpdate` is called for PK=1 +/// - `oldRow` should be the "before" value +/// - `newRow` should be the "after" value +void ExecViewPkOnUpdate() +{ + var playerId = NextId(); + const string before = "before"; + const string after = "after"; + + RunViewPkTest("view-pk-on-update", (conn, pass, fail) => + { + bool sawUpdate = false; + + conn.Reducers.OnInsertViewPkPlayer += (ctx, _, _) => + { + try + { + AssertReducerCommitted("insert_view_pk_player", ctx); + } + catch (Exception ex) + { + fail(ex); + } + }; + + conn.Reducers.OnUpdateViewPkPlayer += (ctx, _, _) => + { + try + { + AssertReducerCommitted("update_view_pk_player", ctx); + } + catch (Exception ex) + { + fail(ex); + } + }; + + conn + .SubscriptionBuilder() + .OnApplied(ctx => + { + try + { + ctx.Db.AllViewPkPlayers.OnUpdate += (_, oldRow, newRow) => + { + try + { + Expect(!sawUpdate, "Expected exactly one OnUpdate callback for view-pk-on-update."); + Expect(oldRow.Id == playerId, $"Expected oldRow.Id={playerId}, got {oldRow.Id}."); + Expect(oldRow.Name == before, $"Expected oldRow.Name={before}, got {oldRow.Name}."); + Expect(newRow.Id == playerId, $"Expected newRow.Id={playerId}, got {newRow.Id}."); + Expect(newRow.Name == after, $"Expected newRow.Name={after}, got {newRow.Name}."); + sawUpdate = true; + pass(); + } + catch (Exception ex) + { + fail(ex); + } + }; + + ctx.Reducers.InsertViewPkPlayer(playerId, before); + ctx.Reducers.UpdateViewPkPlayer(playerId, after); + } + catch (Exception ex) + { + fail(ex); + } + }) + .OnError((_, err) => fail(err)) + .Subscribe(["SELECT * FROM all_view_pk_players"]); + }); +} + +/// Subscribe to a right semijoin whose rhs is a view with primary key. +/// +/// Ensures: +/// 1. A semijoin subscription involving a view is valid +/// 2. The C# SDK emits an `OnUpdate` callback and that the client receives the correct old and new rows +/// +/// Query: +/// SELECT player.* +/// FROM view_pk_membership membership +/// JOIN all_view_pk_players player ON membership.player_id = player.id +/// +/// Test: +/// 1. Insert player row (id=1, "before"). +/// 2. Insert membership row referencing player_id=1, allowing the semijoin match. +/// 3. Update player row to (id=1, "after"). +/// +/// Expect: +/// - `OnUpdate` is called for player PK=1 +/// - `oldRow` should be the "before" value +/// - `newRow` should be the "after" value +void ExecViewPkJoinQueryBuilder() +{ + var playerId = NextId(); + var membershipId = NextId(); + const string before = "before"; + const string after = "after"; + + RunViewPkTest("view-pk-join-query-builder", (conn, pass, fail) => + { + bool sawUpdate = false; + + conn.Reducers.OnInsertViewPkPlayer += (ctx, _, _) => + { + try + { + AssertReducerCommitted("insert_view_pk_player", ctx); + } + catch (Exception ex) + { + fail(ex); + } + }; + + conn.Reducers.OnInsertViewPkMembership += (ctx, _, _) => + { + try + { + AssertReducerCommitted("insert_view_pk_membership", ctx); + } + catch (Exception ex) + { + fail(ex); + } + }; + + conn.Reducers.OnUpdateViewPkPlayer += (ctx, _, _) => + { + try + { + AssertReducerCommitted("update_view_pk_player", ctx); + } + catch (Exception ex) + { + fail(ex); + } + }; + + conn + .SubscriptionBuilder() + .AddQuery(q => + q.From.ViewPkMembership().RightSemijoin( + q.From.AllViewPkPlayers(), + (membership, player) => membership.PlayerId.Eq(player.Id) + ) + ) + .OnApplied(ctx => + { + try + { + ctx.Db.AllViewPkPlayers.OnUpdate += (_, oldRow, newRow) => + { + try + { + Expect(!sawUpdate, "Expected exactly one OnUpdate callback for view-pk-join-query-builder."); + Expect(oldRow.Id == playerId, $"Expected oldRow.Id={playerId}, got {oldRow.Id}."); + Expect(oldRow.Name == before, $"Expected oldRow.Name={before}, got {oldRow.Name}."); + Expect(newRow.Id == playerId, $"Expected newRow.Id={playerId}, got {newRow.Id}."); + Expect(newRow.Name == after, $"Expected newRow.Name={after}, got {newRow.Name}."); + sawUpdate = true; + pass(); + } + catch (Exception ex) + { + fail(ex); + } + }; + + ctx.Reducers.InsertViewPkPlayer(playerId, before); + ctx.Reducers.InsertViewPkMembership(membershipId, playerId); + ctx.Reducers.UpdateViewPkPlayer(playerId, after); + } + catch (Exception ex) + { + fail(ex); + } + }) + .OnError((_, err) => fail(err)) + .Subscribe(); + }); +} + +/// Subscribe to a semijoin between two views with primary keys. +/// +/// Ensures: +/// 1. A semijoin subscription involving a view is valid +/// 2. The C# SDK emits an `OnUpdate` callback and that the client receives the correct old and new rows +/// +/// Query: +/// SELECT b.* +/// FROM sender_view_pk_players_a a +/// JOIN sender_view_pk_players_b b ON a.id = b.id +/// +/// Test: +/// 1. Insert player row (id=1, "before"). +/// 2. Insert membership for sender view A. +/// 3. Insert membership for sender view B. +/// 4. Update player row to (id=1, "after"). +/// +/// Expect: +/// - `OnUpdate` is called for player PK=1 +/// - `oldRow` should be the "before" value +/// - `newRow` should be the "after" value +void ExecViewPkSemijoinTwoSenderViewsQueryBuilder() +{ + var playerId = NextId(); + var membershipAId = NextId(); + var membershipBId = NextId(); + const string before = "before"; + const string after = "after"; + + RunViewPkTest( + "view-pk-semijoin-two-sender-views-query-builder", + (conn, pass, fail) => + { + bool sawUpdate = false; + + conn.Reducers.OnInsertViewPkPlayer += (ctx, _, _) => + { + try + { + AssertReducerCommitted("insert_view_pk_player", ctx); + } + catch (Exception ex) + { + fail(ex); + } + }; + + conn.Reducers.OnInsertViewPkMembership += (ctx, _, _) => + { + try + { + AssertReducerCommitted("insert_view_pk_membership", ctx); + } + catch (Exception ex) + { + fail(ex); + } + }; + + conn.Reducers.OnInsertViewPkMembershipSecondary += (ctx, _, _) => + { + try + { + AssertReducerCommitted("insert_view_pk_membership_secondary", ctx); + } + catch (Exception ex) + { + fail(ex); + } + }; + + conn.Reducers.OnUpdateViewPkPlayer += (ctx, _, _) => + { + try + { + AssertReducerCommitted("update_view_pk_player", ctx); + } + catch (Exception ex) + { + fail(ex); + } + }; + + conn + .SubscriptionBuilder() + .AddQuery(q => + q.From.SenderViewPkPlayersA().RightSemijoin( + q.From.SenderViewPkPlayersB(), + (lhsView, rhsView) => lhsView.Id.Eq(rhsView.Id) + ) + ) + .OnApplied(ctx => + { + try + { + ctx.Db.SenderViewPkPlayersB.OnUpdate += (_, oldRow, newRow) => + { + try + { + Expect(!sawUpdate, "Expected exactly one OnUpdate callback for view-pk-semijoin-two-sender-views-query-builder."); + Expect(oldRow.Id == playerId, $"Expected oldRow.Id={playerId}, got {oldRow.Id}."); + Expect(oldRow.Name == before, $"Expected oldRow.Name={before}, got {oldRow.Name}."); + Expect(newRow.Id == playerId, $"Expected newRow.Id={playerId}, got {newRow.Id}."); + Expect(newRow.Name == after, $"Expected newRow.Name={after}, got {newRow.Name}."); + sawUpdate = true; + pass(); + } + catch (Exception ex) + { + fail(ex); + } + }; + + ctx.Reducers.InsertViewPkPlayer(playerId, before); + ctx.Reducers.InsertViewPkMembership(membershipAId, playerId); + ctx.Reducers.InsertViewPkMembershipSecondary(membershipBId, playerId); + ctx.Reducers.UpdateViewPkPlayer(playerId, after); + } + catch (Exception ex) + { + fail(ex); + } + }) + .OnError((_, err) => fail(err)) + .Subscribe(); + } + ); +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/client.csproj b/sdks/csharp/examples~/regression-tests/view-pk-client/client.csproj new file mode 100644 index 00000000000..c0e1682bcbc --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/client.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + true + + + + + + + diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkMembership.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkMembership.g.cs new file mode 100644 index 00000000000..3b09c569584 --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkMembership.g.cs @@ -0,0 +1,73 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteReducers : RemoteBase + { + public delegate void InsertViewPkMembershipHandler(ReducerEventContext ctx, ulong id, ulong playerId); + public event InsertViewPkMembershipHandler? OnInsertViewPkMembership; + + public void InsertViewPkMembership(ulong id, ulong playerId) + { + conn.InternalCallReducer(new Reducer.InsertViewPkMembership(id, playerId)); + } + + public bool InvokeInsertViewPkMembership(ReducerEventContext ctx, Reducer.InsertViewPkMembership args) + { + if (OnInsertViewPkMembership == null) + { + if (InternalOnUnhandledReducerError != null) + { + switch (ctx.Event.Status) + { + case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break; + case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break; + } + } + return false; + } + OnInsertViewPkMembership( + ctx, + args.Id, + args.PlayerId + ); + return true; + } + } + + public abstract partial class Reducer + { + [SpacetimeDB.Type] + [DataContract] + public sealed partial class InsertViewPkMembership : Reducer, IReducerArgs + { + [DataMember(Name = "id")] + public ulong Id; + [DataMember(Name = "player_id")] + public ulong PlayerId; + + public InsertViewPkMembership( + ulong Id, + ulong PlayerId + ) + { + this.Id = Id; + this.PlayerId = PlayerId; + } + + public InsertViewPkMembership() + { + } + + string IReducerArgs.ReducerName => "insert_view_pk_membership"; + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkMembershipSecondary.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkMembershipSecondary.g.cs new file mode 100644 index 00000000000..4742217d359 --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkMembershipSecondary.g.cs @@ -0,0 +1,73 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteReducers : RemoteBase + { + public delegate void InsertViewPkMembershipSecondaryHandler(ReducerEventContext ctx, ulong id, ulong playerId); + public event InsertViewPkMembershipSecondaryHandler? OnInsertViewPkMembershipSecondary; + + public void InsertViewPkMembershipSecondary(ulong id, ulong playerId) + { + conn.InternalCallReducer(new Reducer.InsertViewPkMembershipSecondary(id, playerId)); + } + + public bool InvokeInsertViewPkMembershipSecondary(ReducerEventContext ctx, Reducer.InsertViewPkMembershipSecondary args) + { + if (OnInsertViewPkMembershipSecondary == null) + { + if (InternalOnUnhandledReducerError != null) + { + switch (ctx.Event.Status) + { + case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break; + case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break; + } + } + return false; + } + OnInsertViewPkMembershipSecondary( + ctx, + args.Id, + args.PlayerId + ); + return true; + } + } + + public abstract partial class Reducer + { + [SpacetimeDB.Type] + [DataContract] + public sealed partial class InsertViewPkMembershipSecondary : Reducer, IReducerArgs + { + [DataMember(Name = "id")] + public ulong Id; + [DataMember(Name = "player_id")] + public ulong PlayerId; + + public InsertViewPkMembershipSecondary( + ulong Id, + ulong PlayerId + ) + { + this.Id = Id; + this.PlayerId = PlayerId; + } + + public InsertViewPkMembershipSecondary() + { + } + + string IReducerArgs.ReducerName => "insert_view_pk_membership_secondary"; + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkPlayer.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkPlayer.g.cs new file mode 100644 index 00000000000..75146b10f70 --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkPlayer.g.cs @@ -0,0 +1,74 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteReducers : RemoteBase + { + public delegate void InsertViewPkPlayerHandler(ReducerEventContext ctx, ulong id, string name); + public event InsertViewPkPlayerHandler? OnInsertViewPkPlayer; + + public void InsertViewPkPlayer(ulong id, string name) + { + conn.InternalCallReducer(new Reducer.InsertViewPkPlayer(id, name)); + } + + public bool InvokeInsertViewPkPlayer(ReducerEventContext ctx, Reducer.InsertViewPkPlayer args) + { + if (OnInsertViewPkPlayer == null) + { + if (InternalOnUnhandledReducerError != null) + { + switch (ctx.Event.Status) + { + case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break; + case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break; + } + } + return false; + } + OnInsertViewPkPlayer( + ctx, + args.Id, + args.Name + ); + return true; + } + } + + public abstract partial class Reducer + { + [SpacetimeDB.Type] + [DataContract] + public sealed partial class InsertViewPkPlayer : Reducer, IReducerArgs + { + [DataMember(Name = "id")] + public ulong Id; + [DataMember(Name = "name")] + public string Name; + + public InsertViewPkPlayer( + ulong Id, + string Name + ) + { + this.Id = Id; + this.Name = Name; + } + + public InsertViewPkPlayer() + { + this.Name = ""; + } + + string IReducerArgs.ReducerName => "insert_view_pk_player"; + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/UpdateViewPkPlayer.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/UpdateViewPkPlayer.g.cs new file mode 100644 index 00000000000..1649aed0401 --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/UpdateViewPkPlayer.g.cs @@ -0,0 +1,74 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteReducers : RemoteBase + { + public delegate void UpdateViewPkPlayerHandler(ReducerEventContext ctx, ulong id, string name); + public event UpdateViewPkPlayerHandler? OnUpdateViewPkPlayer; + + public void UpdateViewPkPlayer(ulong id, string name) + { + conn.InternalCallReducer(new Reducer.UpdateViewPkPlayer(id, name)); + } + + public bool InvokeUpdateViewPkPlayer(ReducerEventContext ctx, Reducer.UpdateViewPkPlayer args) + { + if (OnUpdateViewPkPlayer == null) + { + if (InternalOnUnhandledReducerError != null) + { + switch (ctx.Event.Status) + { + case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break; + case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break; + } + } + return false; + } + OnUpdateViewPkPlayer( + ctx, + args.Id, + args.Name + ); + return true; + } + } + + public abstract partial class Reducer + { + [SpacetimeDB.Type] + [DataContract] + public sealed partial class UpdateViewPkPlayer : Reducer, IReducerArgs + { + [DataMember(Name = "id")] + public ulong Id; + [DataMember(Name = "name")] + public string Name; + + public UpdateViewPkPlayer( + ulong Id, + string Name + ) + { + this.Id = Id; + this.Name = Name; + } + + public UpdateViewPkPlayer() + { + this.Name = ""; + } + + string IReducerArgs.ReducerName => "update_view_pk_player"; + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/SpacetimeDBClient.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/SpacetimeDBClient.g.cs new file mode 100644 index 00000000000..1481a410f82 --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/SpacetimeDBClient.g.cs @@ -0,0 +1,646 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.0.4 (commit 40632f3085ed1d25a1918814f41d15dd61c890b0). + +#nullable enable + +using System; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteReducers : RemoteBase + { + internal RemoteReducers(DbConnection conn) : base(conn) { } + internal event Action? InternalOnUnhandledReducerError; + } + + public sealed partial class RemoteProcedures : RemoteBase + { + internal RemoteProcedures(DbConnection conn) : base(conn) { } + } + + public sealed partial class RemoteTables : RemoteTablesBase + { + public RemoteTables(DbConnection conn) + { + AddTable(AllViewPkPlayers = new(conn)); + AddTable(SenderViewPkPlayersA = new(conn)); + AddTable(SenderViewPkPlayersB = new(conn)); + AddTable(ViewPkMembership = new(conn)); + AddTable(ViewPkMembershipSecondary = new(conn)); + AddTable(ViewPkPlayer = new(conn)); + } + } + + + public interface IRemoteDbContext : IDbContext + { + public event Action? OnUnhandledReducerError; + } + + public sealed class EventContext : IEventContext, IRemoteDbContext + { + private readonly DbConnection conn; + + /// + /// The event that caused this callback to run. + /// + public readonly Event Event; + + /// + /// Access to tables in the client cache, which stores a read-only replica of the remote database state. + /// + /// The returned DbView will have a method to access each table defined by the module. + /// + public RemoteTables Db => conn.Db; + /// + /// Access to reducers defined by the module. + /// + /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, + /// plus methods for adding and removing callbacks on each of those reducers. + /// + public RemoteReducers Reducers => conn.Reducers; + /// + /// Access to procedures defined by the module. + /// + /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, + /// with a callback for when the procedure completes and returns a value. + /// + public RemoteProcedures Procedures => conn.Procedures; + /// + /// Returns true if the connection is active, i.e. has not yet disconnected. + /// + public bool IsActive => conn.IsActive; + /// + /// Close the connection. + /// + /// Throws an error if the connection is already closed. + /// + public void Disconnect() + { + conn.Disconnect(); + } + /// + /// Start building a subscription. + /// + /// A builder-pattern constructor for subscribing to queries, + /// causing matching rows to be replicated into the client cache. + public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); + /// + /// Get the Identity of this connection. + /// + /// This method returns null if the connection was constructed anonymously + /// and we have not yet received our newly-generated Identity from the host. + /// + public Identity? Identity => conn.Identity; + /// + /// Get this connection's ConnectionId. + /// + public ConnectionId ConnectionId => conn.ConnectionId; + /// + /// Register a callback to be called when a reducer with no handler returns an error. + /// + public event Action? OnUnhandledReducerError + { + add => Reducers.InternalOnUnhandledReducerError += value; + remove => Reducers.InternalOnUnhandledReducerError -= value; + } + + internal EventContext(DbConnection conn, Event Event) + { + this.conn = conn; + this.Event = Event; + } + } + + public sealed class ReducerEventContext : IReducerEventContext, IRemoteDbContext + { + private readonly DbConnection conn; + /// + /// The reducer event that caused this callback to run. + /// + public readonly ReducerEvent Event; + + /// + /// Access to tables in the client cache, which stores a read-only replica of the remote database state. + /// + /// The returned DbView will have a method to access each table defined by the module. + /// + public RemoteTables Db => conn.Db; + /// + /// Access to reducers defined by the module. + /// + /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, + /// plus methods for adding and removing callbacks on each of those reducers. + /// + public RemoteReducers Reducers => conn.Reducers; + /// + /// Access to procedures defined by the module. + /// + /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, + /// with a callback for when the procedure completes and returns a value. + /// + public RemoteProcedures Procedures => conn.Procedures; + /// + /// Returns true if the connection is active, i.e. has not yet disconnected. + /// + public bool IsActive => conn.IsActive; + /// + /// Close the connection. + /// + /// Throws an error if the connection is already closed. + /// + public void Disconnect() + { + conn.Disconnect(); + } + /// + /// Start building a subscription. + /// + /// A builder-pattern constructor for subscribing to queries, + /// causing matching rows to be replicated into the client cache. + public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); + /// + /// Get the Identity of this connection. + /// + /// This method returns null if the connection was constructed anonymously + /// and we have not yet received our newly-generated Identity from the host. + /// + public Identity? Identity => conn.Identity; + /// + /// Get this connection's ConnectionId. + /// + public ConnectionId ConnectionId => conn.ConnectionId; + /// + /// Register a callback to be called when a reducer with no handler returns an error. + /// + public event Action? OnUnhandledReducerError + { + add => Reducers.InternalOnUnhandledReducerError += value; + remove => Reducers.InternalOnUnhandledReducerError -= value; + } + + internal ReducerEventContext(DbConnection conn, ReducerEvent reducerEvent) + { + this.conn = conn; + Event = reducerEvent; + } + } + + public sealed class ErrorContext : IErrorContext, IRemoteDbContext + { + private readonly DbConnection conn; + /// + /// The Exception that caused this error callback to be run. + /// + public readonly Exception Event; + Exception IErrorContext.Event + { + get + { + return Event; + } + } + + /// + /// Access to tables in the client cache, which stores a read-only replica of the remote database state. + /// + /// The returned DbView will have a method to access each table defined by the module. + /// + public RemoteTables Db => conn.Db; + /// + /// Access to reducers defined by the module. + /// + /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, + /// plus methods for adding and removing callbacks on each of those reducers. + /// + public RemoteReducers Reducers => conn.Reducers; + /// + /// Access to procedures defined by the module. + /// + /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, + /// with a callback for when the procedure completes and returns a value. + /// + public RemoteProcedures Procedures => conn.Procedures; + /// + /// Returns true if the connection is active, i.e. has not yet disconnected. + /// + public bool IsActive => conn.IsActive; + /// + /// Close the connection. + /// + /// Throws an error if the connection is already closed. + /// + public void Disconnect() + { + conn.Disconnect(); + } + /// + /// Start building a subscription. + /// + /// A builder-pattern constructor for subscribing to queries, + /// causing matching rows to be replicated into the client cache. + public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); + /// + /// Get the Identity of this connection. + /// + /// This method returns null if the connection was constructed anonymously + /// and we have not yet received our newly-generated Identity from the host. + /// + public Identity? Identity => conn.Identity; + /// + /// Get this connection's ConnectionId. + /// + public ConnectionId ConnectionId => conn.ConnectionId; + /// + /// Register a callback to be called when a reducer with no handler returns an error. + /// + public event Action? OnUnhandledReducerError + { + add => Reducers.InternalOnUnhandledReducerError += value; + remove => Reducers.InternalOnUnhandledReducerError -= value; + } + + internal ErrorContext(DbConnection conn, Exception error) + { + this.conn = conn; + Event = error; + } + } + + public sealed class SubscriptionEventContext : ISubscriptionEventContext, IRemoteDbContext + { + private readonly DbConnection conn; + + /// + /// Access to tables in the client cache, which stores a read-only replica of the remote database state. + /// + /// The returned DbView will have a method to access each table defined by the module. + /// + public RemoteTables Db => conn.Db; + /// + /// Access to reducers defined by the module. + /// + /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, + /// plus methods for adding and removing callbacks on each of those reducers. + /// + public RemoteReducers Reducers => conn.Reducers; + /// + /// Access to procedures defined by the module. + /// + /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, + /// with a callback for when the procedure completes and returns a value. + /// + public RemoteProcedures Procedures => conn.Procedures; + /// + /// Returns true if the connection is active, i.e. has not yet disconnected. + /// + public bool IsActive => conn.IsActive; + /// + /// Close the connection. + /// + /// Throws an error if the connection is already closed. + /// + public void Disconnect() + { + conn.Disconnect(); + } + /// + /// Start building a subscription. + /// + /// A builder-pattern constructor for subscribing to queries, + /// causing matching rows to be replicated into the client cache. + public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); + /// + /// Get the Identity of this connection. + /// + /// This method returns null if the connection was constructed anonymously + /// and we have not yet received our newly-generated Identity from the host. + /// + public Identity? Identity => conn.Identity; + /// + /// Get this connection's ConnectionId. + /// + public ConnectionId ConnectionId => conn.ConnectionId; + /// + /// Register a callback to be called when a reducer with no handler returns an error. + /// + public event Action? OnUnhandledReducerError + { + add => Reducers.InternalOnUnhandledReducerError += value; + remove => Reducers.InternalOnUnhandledReducerError -= value; + } + + internal SubscriptionEventContext(DbConnection conn) + { + this.conn = conn; + } + } + + public sealed class ProcedureEventContext : IProcedureEventContext, IRemoteDbContext + { + private readonly DbConnection conn; + /// + /// The procedure event that caused this callback to run. + /// + public readonly ProcedureEvent Event; + + /// + /// Access to tables in the client cache, which stores a read-only replica of the remote database state. + /// + /// The returned DbView will have a method to access each table defined by the module. + /// + public RemoteTables Db => conn.Db; + /// + /// Access to reducers defined by the module. + /// + /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, + /// plus methods for adding and removing callbacks on each of those reducers. + /// + public RemoteReducers Reducers => conn.Reducers; + /// + /// Access to procedures defined by the module. + /// + /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, + /// with a callback for when the procedure completes and returns a value. + /// + public RemoteProcedures Procedures => conn.Procedures; + /// + /// Returns true if the connection is active, i.e. has not yet disconnected. + /// + public bool IsActive => conn.IsActive; + /// + /// Close the connection. + /// + /// Throws an error if the connection is already closed. + /// + public void Disconnect() + { + conn.Disconnect(); + } + /// + /// Start building a subscription. + /// + /// A builder-pattern constructor for subscribing to queries, + /// causing matching rows to be replicated into the client cache. + public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); + /// + /// Get the Identity of this connection. + /// + /// This method returns null if the connection was constructed anonymously + /// and we have not yet received our newly-generated Identity from the host. + /// + public Identity? Identity => conn.Identity; + /// + /// Get this connection's ConnectionId. + /// + public ConnectionId ConnectionId => conn.ConnectionId; + /// + /// Register a callback to be called when a reducer with no handler returns an error. + /// + public event Action? OnUnhandledReducerError + { + add => Reducers.InternalOnUnhandledReducerError += value; + remove => Reducers.InternalOnUnhandledReducerError -= value; + } + + internal ProcedureEventContext(DbConnection conn, ProcedureEvent Event) + { + this.conn = conn; + this.Event = Event; + } + } + + /// + /// Builder-pattern constructor for subscription queries. + /// + public sealed class SubscriptionBuilder + { + private readonly IDbConnection conn; + + private event Action? Applied; + private event Action? Error; + + /// + /// Private API, use conn.SubscriptionBuilder() instead. + /// + public SubscriptionBuilder(IDbConnection conn) + { + this.conn = conn; + } + + /// + /// Register a callback to run when the subscription is applied. + /// + public SubscriptionBuilder OnApplied( + Action callback + ) + { + Applied += callback; + return this; + } + + /// + /// Register a callback to run when the subscription fails. + /// + /// Note that this callback may run either when attempting to apply the subscription, + /// in which case Self::on_applied will never run, + /// or later during the subscription's lifetime if the module's interface changes, + /// in which case Self::on_applied may have already run. + /// + public SubscriptionBuilder OnError( + Action callback + ) + { + Error += callback; + return this; + } + + /// + /// Add a typed query to this subscription. + /// + /// This is the entry point for building subscriptions without writing SQL by hand. + /// Once a typed query is added, only typed queries may follow (SQL and typed queries cannot be mixed). + /// + public TypedSubscriptionBuilder AddQuery( + Func> build + ) + { + var typed = new TypedSubscriptionBuilder(conn, Applied, Error); + return typed.AddQuery(build); + } + + /// + /// Subscribe to the following SQL queries. + /// + /// This method returns immediately, with the data not yet added to the DbConnection. + /// The provided callbacks will be invoked once the data is returned from the remote server. + /// Data from all the provided queries will be returned at the same time. + /// + /// See the SpacetimeDB SQL docs for more information on SQL syntax: + /// https://spacetimedb.com/docs/sql + /// + public SubscriptionHandle Subscribe( + string[] querySqls + ) => new(conn, Applied, Error, querySqls); + + /// + /// Subscribe to all rows from all tables. + /// + /// This method is intended as a convenience + /// for applications where client-side memory use and network bandwidth are not concerns. + /// Applications where these resources are a constraint + /// should register more precise queries via Self.Subscribe + /// in order to replicate only the subset of data which the client needs to function. + /// + /// This method should not be combined with Self.Subscribe on the same DbConnection. + /// A connection may either Self.Subscribe to particular queries, + /// or Self.SubscribeToAllTables, but not both. + /// Attempting to call Self.Subscribe + /// on a DbConnection that has previously used Self.SubscribeToAllTables, + /// or vice versa, may misbehave in any number of ways, + /// including dropping subscriptions, corrupting the client cache, or panicking. + /// + public SubscriptionHandle SubscribeToAllTables() => + new(conn, Applied, Error, QueryBuilder.AllTablesSqlQueries()); + } + + public sealed class SubscriptionHandle : SubscriptionHandleBase + { + /// + /// Internal API. Construct SubscriptionHandles using conn.SubscriptionBuilder. + /// + public SubscriptionHandle( + IDbConnection conn, + Action? onApplied, + Action? onError, + string[] querySqls + ) : base(conn, onApplied, onError, querySqls) + { } + } + + public sealed class QueryBuilder + { + public From From { get; } = new(); + + internal static string[] AllTablesSqlQueries() => new string[] + { + new QueryBuilder().From.AllViewPkPlayers().ToSql(), + new QueryBuilder().From.SenderViewPkPlayersA().ToSql(), + new QueryBuilder().From.SenderViewPkPlayersB().ToSql(), + new QueryBuilder().From.ViewPkMembership().ToSql(), + new QueryBuilder().From.ViewPkMembershipSecondary().ToSql(), + new QueryBuilder().From.ViewPkPlayer().ToSql(), + } + ; + } + + public sealed class From + { + public global::SpacetimeDB.Table AllViewPkPlayers() => new("all_view_pk_players", new AllViewPkPlayersCols("all_view_pk_players"), new AllViewPkPlayersIxCols("all_view_pk_players")); + public global::SpacetimeDB.Table SenderViewPkPlayersA() => new("sender_view_pk_players_a", new SenderViewPkPlayersACols("sender_view_pk_players_a"), new SenderViewPkPlayersAIxCols("sender_view_pk_players_a")); + public global::SpacetimeDB.Table SenderViewPkPlayersB() => new("sender_view_pk_players_b", new SenderViewPkPlayersBCols("sender_view_pk_players_b"), new SenderViewPkPlayersBIxCols("sender_view_pk_players_b")); + public global::SpacetimeDB.Table ViewPkMembership() => new("view_pk_membership", new ViewPkMembershipCols("view_pk_membership"), new ViewPkMembershipIxCols("view_pk_membership")); + public global::SpacetimeDB.Table ViewPkMembershipSecondary() => new("view_pk_membership_secondary", new ViewPkMembershipSecondaryCols("view_pk_membership_secondary"), new ViewPkMembershipSecondaryIxCols("view_pk_membership_secondary")); + public global::SpacetimeDB.Table ViewPkPlayer() => new("view_pk_player", new ViewPkPlayerCols("view_pk_player"), new ViewPkPlayerIxCols("view_pk_player")); + } + + public sealed class TypedSubscriptionBuilder + { + private readonly IDbConnection conn; + private Action? Applied; + private Action? Error; + private readonly List querySqls = new(); + + internal TypedSubscriptionBuilder(IDbConnection conn, Action? applied, Action? error) + { + this.conn = conn; + Applied = applied; + Error = error; + } + + public TypedSubscriptionBuilder OnApplied(Action callback) + { + Applied += callback; + return this; + } + + public TypedSubscriptionBuilder OnError(Action callback) + { + Error += callback; + return this; + } + + public TypedSubscriptionBuilder AddQuery(Func> build) + { + var qb = new QueryBuilder(); + querySqls.Add(build(qb).ToSql()); + return this; + } + + public SubscriptionHandle Subscribe() => new(conn, Applied, Error, querySqls.ToArray()); + } + + public abstract partial class Reducer + { + private Reducer() { } + } + + public abstract partial class Procedure + { + private Procedure() { } + } + + public sealed class DbConnection : DbConnectionBase + { + public override RemoteTables Db { get; } + public readonly RemoteReducers Reducers; + public readonly RemoteProcedures Procedures; + + public DbConnection() + { + Db = new(this); + Reducers = new(this); + Procedures = new(this); + } + + protected override IEventContext ToEventContext(Event Event) => + new EventContext(this, Event); + + protected override IReducerEventContext ToReducerEventContext(ReducerEvent reducerEvent) => + new ReducerEventContext(this, reducerEvent); + + protected override ISubscriptionEventContext MakeSubscriptionEventContext() => + new SubscriptionEventContext(this); + + protected override IErrorContext ToErrorContext(Exception exception) => + new ErrorContext(this, exception); + + protected override IProcedureEventContext ToProcedureEventContext(ProcedureEvent procedureEvent) => + new ProcedureEventContext(this, procedureEvent); + + protected override bool Dispatch(IReducerEventContext context, Reducer reducer) + { + var eventContext = (ReducerEventContext)context; + return reducer switch + { + Reducer.InsertViewPkMembership args => Reducers.InvokeInsertViewPkMembership(eventContext, args), + Reducer.InsertViewPkMembershipSecondary args => Reducers.InvokeInsertViewPkMembershipSecondary(eventContext, args), + Reducer.InsertViewPkPlayer args => Reducers.InvokeInsertViewPkPlayer(eventContext, args), + Reducer.UpdateViewPkPlayer args => Reducers.InvokeUpdateViewPkPlayer(eventContext, args), + _ => throw new ArgumentOutOfRangeException("Reducer", $"Unknown reducer {reducer}") + }; + } + + public SubscriptionBuilder SubscriptionBuilder() => new(this); + public event Action OnUnhandledReducerError + { + add => Reducers.InternalOnUnhandledReducerError += value; + remove => Reducers.InternalOnUnhandledReducerError -= value; + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/AllViewPkPlayers.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/AllViewPkPlayers.g.cs new file mode 100644 index 00000000000..98b348c7f09 --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/AllViewPkPlayers.g.cs @@ -0,0 +1,51 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using SpacetimeDB.BSATN; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteTables + { + public sealed class AllViewPkPlayersHandle : RemoteTableHandle + { + protected override string RemoteTableName => "all_view_pk_players"; + + internal AllViewPkPlayersHandle(DbConnection conn) : base(conn) + { + } + + protected override object GetPrimaryKey(ViewPkPlayer row) => row.Id; + } + + public readonly AllViewPkPlayersHandle AllViewPkPlayers; + } + + public sealed class AllViewPkPlayersCols + { + public global::SpacetimeDB.Col Id { get; } + public global::SpacetimeDB.Col Name { get; } + + public AllViewPkPlayersCols(string tableName) + { + Id = new global::SpacetimeDB.Col(tableName, "id"); + Name = new global::SpacetimeDB.Col(tableName, "name"); + } + } + + public sealed class AllViewPkPlayersIxCols + { + public global::SpacetimeDB.IxCol Id { get; } + + public AllViewPkPlayersIxCols(string tableName) + { + Id = new global::SpacetimeDB.IxCol(tableName, "id"); + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/SenderViewPkPlayersA.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/SenderViewPkPlayersA.g.cs new file mode 100644 index 00000000000..bc23d9e62ef --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/SenderViewPkPlayersA.g.cs @@ -0,0 +1,51 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using SpacetimeDB.BSATN; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteTables + { + public sealed class SenderViewPkPlayersAHandle : RemoteTableHandle + { + protected override string RemoteTableName => "sender_view_pk_players_a"; + + internal SenderViewPkPlayersAHandle(DbConnection conn) : base(conn) + { + } + + protected override object GetPrimaryKey(ViewPkPlayer row) => row.Id; + } + + public readonly SenderViewPkPlayersAHandle SenderViewPkPlayersA; + } + + public sealed class SenderViewPkPlayersACols + { + public global::SpacetimeDB.Col Id { get; } + public global::SpacetimeDB.Col Name { get; } + + public SenderViewPkPlayersACols(string tableName) + { + Id = new global::SpacetimeDB.Col(tableName, "id"); + Name = new global::SpacetimeDB.Col(tableName, "name"); + } + } + + public sealed class SenderViewPkPlayersAIxCols + { + public global::SpacetimeDB.IxCol Id { get; } + + public SenderViewPkPlayersAIxCols(string tableName) + { + Id = new global::SpacetimeDB.IxCol(tableName, "id"); + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/SenderViewPkPlayersB.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/SenderViewPkPlayersB.g.cs new file mode 100644 index 00000000000..214837f8276 --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/SenderViewPkPlayersB.g.cs @@ -0,0 +1,51 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using SpacetimeDB.BSATN; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteTables + { + public sealed class SenderViewPkPlayersBHandle : RemoteTableHandle + { + protected override string RemoteTableName => "sender_view_pk_players_b"; + + internal SenderViewPkPlayersBHandle(DbConnection conn) : base(conn) + { + } + + protected override object GetPrimaryKey(ViewPkPlayer row) => row.Id; + } + + public readonly SenderViewPkPlayersBHandle SenderViewPkPlayersB; + } + + public sealed class SenderViewPkPlayersBCols + { + public global::SpacetimeDB.Col Id { get; } + public global::SpacetimeDB.Col Name { get; } + + public SenderViewPkPlayersBCols(string tableName) + { + Id = new global::SpacetimeDB.Col(tableName, "id"); + Name = new global::SpacetimeDB.Col(tableName, "name"); + } + } + + public sealed class SenderViewPkPlayersBIxCols + { + public global::SpacetimeDB.IxCol Id { get; } + + public SenderViewPkPlayersBIxCols(string tableName) + { + Id = new global::SpacetimeDB.IxCol(tableName, "id"); + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkMembership.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkMembership.g.cs new file mode 100644 index 00000000000..8c0339e7b86 --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkMembership.g.cs @@ -0,0 +1,73 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using SpacetimeDB.BSATN; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteTables + { + public sealed class ViewPkMembershipHandle : RemoteTableHandle + { + protected override string RemoteTableName => "view_pk_membership"; + + public sealed class IdUniqueIndex : UniqueIndexBase + { + protected override ulong GetKey(ViewPkMembership row) => row.Id; + + public IdUniqueIndex(ViewPkMembershipHandle table) : base(table) { } + } + + public readonly IdUniqueIndex Id; + + public sealed class PlayerIdIndex : BTreeIndexBase + { + protected override ulong GetKey(ViewPkMembership row) => row.PlayerId; + + public PlayerIdIndex(ViewPkMembershipHandle table) : base(table) { } + } + + public readonly PlayerIdIndex PlayerId; + + internal ViewPkMembershipHandle(DbConnection conn) : base(conn) + { + Id = new(this); + PlayerId = new(this); + } + + protected override object GetPrimaryKey(ViewPkMembership row) => row.Id; + } + + public readonly ViewPkMembershipHandle ViewPkMembership; + } + + public sealed class ViewPkMembershipCols + { + public global::SpacetimeDB.Col Id { get; } + public global::SpacetimeDB.Col PlayerId { get; } + + public ViewPkMembershipCols(string tableName) + { + Id = new global::SpacetimeDB.Col(tableName, "id"); + PlayerId = new global::SpacetimeDB.Col(tableName, "player_id"); + } + } + + public sealed class ViewPkMembershipIxCols + { + public global::SpacetimeDB.IxCol Id { get; } + public global::SpacetimeDB.IxCol PlayerId { get; } + + public ViewPkMembershipIxCols(string tableName) + { + Id = new global::SpacetimeDB.IxCol(tableName, "id"); + PlayerId = new global::SpacetimeDB.IxCol(tableName, "player_id"); + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkMembershipSecondary.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkMembershipSecondary.g.cs new file mode 100644 index 00000000000..ec4bf1ff25a --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkMembershipSecondary.g.cs @@ -0,0 +1,73 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using SpacetimeDB.BSATN; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteTables + { + public sealed class ViewPkMembershipSecondaryHandle : RemoteTableHandle + { + protected override string RemoteTableName => "view_pk_membership_secondary"; + + public sealed class IdUniqueIndex : UniqueIndexBase + { + protected override ulong GetKey(ViewPkMembershipSecondary row) => row.Id; + + public IdUniqueIndex(ViewPkMembershipSecondaryHandle table) : base(table) { } + } + + public readonly IdUniqueIndex Id; + + public sealed class PlayerIdIndex : BTreeIndexBase + { + protected override ulong GetKey(ViewPkMembershipSecondary row) => row.PlayerId; + + public PlayerIdIndex(ViewPkMembershipSecondaryHandle table) : base(table) { } + } + + public readonly PlayerIdIndex PlayerId; + + internal ViewPkMembershipSecondaryHandle(DbConnection conn) : base(conn) + { + Id = new(this); + PlayerId = new(this); + } + + protected override object GetPrimaryKey(ViewPkMembershipSecondary row) => row.Id; + } + + public readonly ViewPkMembershipSecondaryHandle ViewPkMembershipSecondary; + } + + public sealed class ViewPkMembershipSecondaryCols + { + public global::SpacetimeDB.Col Id { get; } + public global::SpacetimeDB.Col PlayerId { get; } + + public ViewPkMembershipSecondaryCols(string tableName) + { + Id = new global::SpacetimeDB.Col(tableName, "id"); + PlayerId = new global::SpacetimeDB.Col(tableName, "player_id"); + } + } + + public sealed class ViewPkMembershipSecondaryIxCols + { + public global::SpacetimeDB.IxCol Id { get; } + public global::SpacetimeDB.IxCol PlayerId { get; } + + public ViewPkMembershipSecondaryIxCols(string tableName) + { + Id = new global::SpacetimeDB.IxCol(tableName, "id"); + PlayerId = new global::SpacetimeDB.IxCol(tableName, "player_id"); + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkPlayer.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkPlayer.g.cs new file mode 100644 index 00000000000..f420e3e2586 --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkPlayer.g.cs @@ -0,0 +1,61 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using SpacetimeDB.BSATN; +using SpacetimeDB.ClientApi; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + public sealed partial class RemoteTables + { + public sealed class ViewPkPlayerHandle : RemoteTableHandle + { + protected override string RemoteTableName => "view_pk_player"; + + public sealed class IdUniqueIndex : UniqueIndexBase + { + protected override ulong GetKey(ViewPkPlayer row) => row.Id; + + public IdUniqueIndex(ViewPkPlayerHandle table) : base(table) { } + } + + public readonly IdUniqueIndex Id; + + internal ViewPkPlayerHandle(DbConnection conn) : base(conn) + { + Id = new(this); + } + + protected override object GetPrimaryKey(ViewPkPlayer row) => row.Id; + } + + public readonly ViewPkPlayerHandle ViewPkPlayer; + } + + public sealed class ViewPkPlayerCols + { + public global::SpacetimeDB.Col Id { get; } + public global::SpacetimeDB.Col Name { get; } + + public ViewPkPlayerCols(string tableName) + { + Id = new global::SpacetimeDB.Col(tableName, "id"); + Name = new global::SpacetimeDB.Col(tableName, "name"); + } + } + + public sealed class ViewPkPlayerIxCols + { + public global::SpacetimeDB.IxCol Id { get; } + + public ViewPkPlayerIxCols(string tableName) + { + Id = new global::SpacetimeDB.IxCol(tableName, "id"); + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkMembership.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkMembership.g.cs new file mode 100644 index 00000000000..330ba5af904 --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkMembership.g.cs @@ -0,0 +1,34 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + [SpacetimeDB.Type] + [DataContract] + public sealed partial class ViewPkMembership + { + [DataMember(Name = "id")] + public ulong Id; + [DataMember(Name = "player_id")] + public ulong PlayerId; + + public ViewPkMembership( + ulong Id, + ulong PlayerId + ) + { + this.Id = Id; + this.PlayerId = PlayerId; + } + + public ViewPkMembership() + { + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkMembershipSecondary.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkMembershipSecondary.g.cs new file mode 100644 index 00000000000..aec64d3466b --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkMembershipSecondary.g.cs @@ -0,0 +1,34 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + [SpacetimeDB.Type] + [DataContract] + public sealed partial class ViewPkMembershipSecondary + { + [DataMember(Name = "id")] + public ulong Id; + [DataMember(Name = "player_id")] + public ulong PlayerId; + + public ViewPkMembershipSecondary( + ulong Id, + ulong PlayerId + ) + { + this.Id = Id; + this.PlayerId = PlayerId; + } + + public ViewPkMembershipSecondary() + { + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkPlayer.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkPlayer.g.cs new file mode 100644 index 00000000000..b3b1da245ca --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkPlayer.g.cs @@ -0,0 +1,35 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace SpacetimeDB.Types +{ + [SpacetimeDB.Type] + [DataContract] + public sealed partial class ViewPkPlayer + { + [DataMember(Name = "id")] + public ulong Id; + [DataMember(Name = "name")] + public string Name; + + public ViewPkPlayer( + ulong Id, + string Name + ) + { + this.Id = Id; + this.Name = Name; + } + + public ViewPkPlayer() + { + this.Name = ""; + } + } +} diff --git a/sdks/csharp/tools~/gen-regression-tests.sh b/sdks/csharp/tools~/gen-regression-tests.sh index 8936971cd5a..451a2131a60 100755 --- a/sdks/csharp/tools~/gen-regression-tests.sh +++ b/sdks/csharp/tools~/gen-regression-tests.sh @@ -10,3 +10,4 @@ cargo build --manifest-path "$STDB_PATH/crates/standalone/Cargo.toml" cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- generate -y -l csharp -o "$SDK_PATH/examples~/regression-tests/client/module_bindings" --module-path "$SDK_PATH/examples~/regression-tests/server" cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- generate -y -l csharp -o "$SDK_PATH/examples~/regression-tests/republishing/client/module_bindings" --module-path "$SDK_PATH/examples~/regression-tests/republishing/server-republish" cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- generate -y -l csharp -o "$SDK_PATH/examples~/regression-tests/procedure-client/module_bindings" --module-path "$STDB_PATH/modules/sdk-test-procedure" +cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- generate -y -l csharp -o "$SDK_PATH/examples~/regression-tests/view-pk-client/module_bindings" --module-path "$STDB_PATH/modules/sdk-test-view-pk-cs" diff --git a/sdks/rust/tests/test.rs b/sdks/rust/tests/test.rs index ed623c7d36f..02f0357a524 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -528,3 +528,4 @@ macro_rules! view_pk_tests { } view_pk_tests!(rust_view_pk, ""); +view_pk_tests!(csharp_view_pk, "-cs"); From 16695dbb748a57630cdb1b6109c40ea84aff2458 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Wed, 11 Mar 2026 14:24:00 -0700 Subject: [PATCH 2/8] fix lint --- crates/bindings-csharp/Codegen/Module.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/bindings-csharp/Codegen/Module.cs b/crates/bindings-csharp/Codegen/Module.cs index 0d555e7548e..84c1bf96684 100644 --- a/crates/bindings-csharp/Codegen/Module.cs +++ b/crates/bindings-csharp/Codegen/Module.cs @@ -1230,10 +1230,9 @@ method.ReturnType is INamedTypeSymbol public string GenerateViewDef(uint Index) { - var returnTypeExpr = - ReturnsQuery - ? $"new global::SpacetimeDB.BSATN.AlgebraicType.Product([new global::SpacetimeDB.BSATN.AggregateElement(\"__query__\", new global::SpacetimeDB.BSATN.AlgebraicType.Ref(new {QueryRowType!.BSATNName}().GetAlgebraicType(registrar).Ref_))])" - : $"new {ReturnType.BSATNName}().GetAlgebraicType(registrar)"; + var returnTypeExpr = ReturnsQuery + ? $"new global::SpacetimeDB.BSATN.AlgebraicType.Product([new global::SpacetimeDB.BSATN.AggregateElement(\"__query__\", new global::SpacetimeDB.BSATN.AlgebraicType.Ref(new {QueryRowType!.BSATNName}().GetAlgebraicType(registrar).Ref_))])" + : $"new {ReturnType.BSATNName}().GetAlgebraicType(registrar)"; return $$$""" new global::SpacetimeDB.Internal.RawViewDefV10( SourceName: "{{{Name}}}", From 385d404cad134c16afe8b12bc8cf347a97878e3a Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Wed, 11 Mar 2026 14:53:36 -0700 Subject: [PATCH 3/8] sats constructor --- .../bindings-csharp/BSATN.Runtime/BSATN/AlgebraicType.cs | 5 +++++ .../fixtures/server/snapshots/Module#FFI.verified.cs | 9 ++------- crates/bindings-csharp/Codegen/Module.cs | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/bindings-csharp/BSATN.Runtime/BSATN/AlgebraicType.cs b/crates/bindings-csharp/BSATN.Runtime/BSATN/AlgebraicType.cs index b88ae42dcb4..43662c057a0 100644 --- a/crates/bindings-csharp/BSATN.Runtime/BSATN/AlgebraicType.cs +++ b/crates/bindings-csharp/BSATN.Runtime/BSATN/AlgebraicType.cs @@ -39,6 +39,7 @@ Unit F64 )> { public static readonly AlgebraicType Unit = new Product([]); + public const string QueryBuilderProductTypeTag = "__query__"; // Special AlgebraicType that can be recognised by the SpacetimeDB `generate` CLI as an Option. internal static AlgebraicType MakeOption(AlgebraicType someType) => @@ -47,4 +48,8 @@ internal static AlgebraicType MakeOption(AlgebraicType someType) => // Special AlgebraicType that can be recognised by the SpacetimeDB `generate` CLI as a Result. internal static AlgebraicType MakeResult(AlgebraicType okType, AlgebraicType errType) => new Sum([new("ok", okType), new("err", errType)]); + + // Special AlgebraicType that can be recognised by the SpacetimeDB `generate` CLI as Query. + public static AlgebraicType MakeQueryBuilderProductType(Ref rowProductTypeRef) => + new Product([new(QueryBuilderProductTypeTag, rowProductTypeRef)]); } diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs index 9bbe0b3ddc0..260b42c3098 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs @@ -1636,13 +1636,8 @@ SpacetimeDB.BSATN.ITypeRegistrar registrar IsPublic: true, IsAnonymous: false, Params: [], - ReturnType: new global::SpacetimeDB.BSATN.AlgebraicType.Product( - [ - new global::SpacetimeDB.BSATN.AggregateElement( - "__query__", - new PublicTable.BSATN().GetAlgebraicType(registrar) - ) - ] + ReturnType: global::SpacetimeDB.BSATN.AlgebraicType.MakeQueryBuilderProductType( + new PublicTable.BSATN().GetAlgebraicType(registrar) ) ); diff --git a/crates/bindings-csharp/Codegen/Module.cs b/crates/bindings-csharp/Codegen/Module.cs index 84c1bf96684..111503f0472 100644 --- a/crates/bindings-csharp/Codegen/Module.cs +++ b/crates/bindings-csharp/Codegen/Module.cs @@ -1231,7 +1231,7 @@ method.ReturnType is INamedTypeSymbol public string GenerateViewDef(uint Index) { var returnTypeExpr = ReturnsQuery - ? $"new global::SpacetimeDB.BSATN.AlgebraicType.Product([new global::SpacetimeDB.BSATN.AggregateElement(\"__query__\", new global::SpacetimeDB.BSATN.AlgebraicType.Ref(new {QueryRowType!.BSATNName}().GetAlgebraicType(registrar).Ref_))])" + ? $"global::SpacetimeDB.BSATN.AlgebraicType.MakeQueryBuilderProductType(new {QueryRowType!.BSATNName}().GetAlgebraicType(registrar))" : $"new {ReturnType.BSATNName}().GetAlgebraicType(registrar)"; return $$$""" new global::SpacetimeDB.Internal.RawViewDefV10( From 4569146154cb9e20c469febe562c8ae5d4bc51c9 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Wed, 11 Mar 2026 16:20:24 -0700 Subject: [PATCH 4/8] regen bindings --- .../client/module_bindings/SpacetimeDBClient.g.cs | 2 +- .../client/module_bindings/Tables/WhereTestQuery.g.cs | 4 ++++ .../procedure-client/module_bindings/SpacetimeDBClient.g.cs | 2 +- .../view-pk-client/module_bindings/SpacetimeDBClient.g.cs | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/sdks/csharp/examples~/regression-tests/client/module_bindings/SpacetimeDBClient.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/SpacetimeDBClient.g.cs index e6d99f9b15b..52b4bb8deb7 100644 --- a/sdks/csharp/examples~/regression-tests/client/module_bindings/SpacetimeDBClient.g.cs +++ b/sdks/csharp/examples~/regression-tests/client/module_bindings/SpacetimeDBClient.g.cs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.0 (commit 6a6b5a6616f0578aa641bc0689691f953b13feb8). +// This was generated using spacetimedb cli version 2.0.4 (commit dfc726be29516b8cdecc651f5c9705026a624a04). #nullable enable diff --git a/sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/WhereTestQuery.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/WhereTestQuery.g.cs index 8e039b09042..6ebbfe7db97 100644 --- a/sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/WhereTestQuery.g.cs +++ b/sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/WhereTestQuery.g.cs @@ -20,6 +20,8 @@ public sealed class WhereTestQueryHandle : RemoteTableHandle row.Id; } public readonly WhereTestQueryHandle WhereTestQuery; @@ -41,9 +43,11 @@ public WhereTestQueryCols(string tableName) public sealed class WhereTestQueryIxCols { + public global::SpacetimeDB.IxCol Id { get; } public WhereTestQueryIxCols(string tableName) { + Id = new global::SpacetimeDB.IxCol(tableName, "id"); } } } diff --git a/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/SpacetimeDBClient.g.cs b/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/SpacetimeDBClient.g.cs index 59344f7ba69..948b4e405d7 100644 --- a/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/SpacetimeDBClient.g.cs +++ b/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/SpacetimeDBClient.g.cs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.0 (commit 9e0e81a6aaec6bf3619cfb9f7916743d86ab7ffc). +// This was generated using spacetimedb cli version 2.0.4 (commit dfc726be29516b8cdecc651f5c9705026a624a04). #nullable enable diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/SpacetimeDBClient.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/SpacetimeDBClient.g.cs index 1481a410f82..a43949b6a86 100644 --- a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/SpacetimeDBClient.g.cs +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/SpacetimeDBClient.g.cs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.4 (commit 40632f3085ed1d25a1918814f41d15dd61c890b0). +// This was generated using spacetimedb cli version 2.0.4 (commit dfc726be29516b8cdecc651f5c9705026a624a04). #nullable enable From 8baa986fb05a734611a89182d36865a2071c4795 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Thu, 12 Mar 2026 09:32:39 -0700 Subject: [PATCH 5/8] run regression tests in ci --- sdks/csharp/tools~/run-regression-tests.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sdks/csharp/tools~/run-regression-tests.sh b/sdks/csharp/tools~/run-regression-tests.sh index fdd7733bf65..8ce171384cb 100644 --- a/sdks/csharp/tools~/run-regression-tests.sh +++ b/sdks/csharp/tools~/run-regression-tests.sh @@ -23,14 +23,18 @@ cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" call --server local cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish --server local -p "$SDK_PATH/examples~/regression-tests/republishing/server-republish" --break-clients republish-test cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" call --server local republish-test insert 2 -echo "Cleanup obj~ folders generated in $SDK_PATH/examples~/regression-tests/procedure-client" +echo "Cleanup obj~ folders generated in $SDK_PATH/examples~/regression-tests/procedure-client and $SDK_PATH/examples~/regression-tests/view-pk-client" # There is a bug in the code generator that creates obj~ folders in the output directory using a Rust project. rm -rf "$SDK_PATH/examples~/regression-tests/procedure-client"/*/obj~ rm -rf "$SDK_PATH/examples~/regression-tests/procedure-client/module_bindings"/*/obj~ +rm -rf "$SDK_PATH/examples~/regression-tests/view-pk-client"/*/obj~ +rm -rf "$SDK_PATH/examples~/regression-tests/view-pk-client/module_bindings"/*/obj~ # Publish module for procedure tests cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -c -y --server local -p "$STDB_PATH/modules/sdk-test-procedure" procedure-tests +# Publish module for view-pk tests +cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -c -y --server local -p "$STDB_PATH/modules/sdk-test-view-pk-cs" view-pk-tests # Run client for btree test cd "$SDK_PATH/examples~/regression-tests/client" && dotnet run -c Debug @@ -40,3 +44,6 @@ cd "$SDK_PATH/examples~/regression-tests/republishing/client" && dotnet run -c D # Run client for procedure test cd "$SDK_PATH/examples~/regression-tests/procedure-client" && dotnet run -c Debug + +# Run client for view-pk tests +cd "$SDK_PATH/examples~/regression-tests/view-pk-client" && dotnet run -c Debug From cf8fb054bb04a28855fabc30d63bb5e37d98472a Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Thu, 12 Mar 2026 11:29:49 -0700 Subject: [PATCH 6/8] shared regression test utilities --- .../regression-tests/client/Program.cs | 53 +- .../regression-tests/client/client.csproj | 4 + .../procedure-client/Program.cs | 48 +- .../procedure-client/client.csproj | 4 + .../republishing/client/Program.cs | 48 +- .../republishing/client/client.csproj | 4 + .../shared/RegressionTestHarness.cs | 206 ++++++++ .../view-pk-client/Program.cs | 469 ++++++------------ .../view-pk-client/client.csproj | 4 + 9 files changed, 384 insertions(+), 456 deletions(-) create mode 100644 sdks/csharp/examples~/regression-tests/shared/RegressionTestHarness.cs diff --git a/sdks/csharp/examples~/regression-tests/client/Program.cs b/sdks/csharp/examples~/regression-tests/client/Program.cs index dda45f13468..c6fc59619c4 100644 --- a/sdks/csharp/examples~/regression-tests/client/Program.cs +++ b/sdks/csharp/examples~/regression-tests/client/Program.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Runtime.CompilerServices; using System.Threading; +using RegressionTests.Shared; using SpacetimeDB; using SpacetimeDB.Types; @@ -19,37 +20,6 @@ const string EXPECTED_TEST_EVENT_NAME = "hello"; const ulong EXPECTED_TEST_EVENT_VALUE = 42; -DbConnection ConnectToDB() -{ - DbConnection? conn = null; - conn = DbConnection - .Builder() - .WithUri(HOST) - .WithDatabaseName(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; @@ -1129,24 +1099,9 @@ ProcedureCallbackResult> result ); } -System.AppDomain.CurrentDomain.UnhandledException += (sender, args) => -{ - Log.Exception($"Unhandled exception: {sender} {args}"); - Environment.Exit(1); -}; -var db = ConnectToDB(); -Log.Info("Starting timer"); +RegressionTestHarness.RegisterUnhandledExceptionExitHandler(); +var db = RegressionTestHarness.ConnectToDatabase(HOST, DBNAME, OnConnected); 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); - } -} +RegressionTestHarness.FrameTickUntilComplete(db, () => applied && waiting == 0, TIMEOUT); Log.Info("Success"); Environment.Exit(0); diff --git a/sdks/csharp/examples~/regression-tests/client/client.csproj b/sdks/csharp/examples~/regression-tests/client/client.csproj index c76a780a74d..540e15ad427 100644 --- a/sdks/csharp/examples~/regression-tests/client/client.csproj +++ b/sdks/csharp/examples~/regression-tests/client/client.csproj @@ -12,4 +12,8 @@ + + + + diff --git a/sdks/csharp/examples~/regression-tests/procedure-client/Program.cs b/sdks/csharp/examples~/regression-tests/procedure-client/Program.cs index 95e1a0f6508..8997f596354 100644 --- a/sdks/csharp/examples~/regression-tests/procedure-client/Program.cs +++ b/sdks/csharp/examples~/regression-tests/procedure-client/Program.cs @@ -5,38 +5,13 @@ using System.Diagnostics; using System.Runtime.CompilerServices; +using RegressionTests.Shared; using SpacetimeDB; using SpacetimeDB.Types; const string HOST = "http://localhost:3000"; const string DBNAME = "procedure-tests"; -DbConnection ConnectToDB() -{ - DbConnection? conn = null; - conn = DbConnection.Builder() - .WithUri(HOST) - .WithDatabaseName(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; bool applied = false; @@ -201,24 +176,9 @@ void OnSubscriptionApplied(SubscriptionEventContext context) }); } -System.AppDomain.CurrentDomain.UnhandledException += (sender, args) => -{ - Log.Exception($"Unhandled exception: {sender} {args}"); - Environment.Exit(1); -}; -var db = ConnectToDB(); -Log.Info("Starting timer"); +RegressionTestHarness.RegisterUnhandledExceptionExitHandler(); +var db = RegressionTestHarness.ConnectToDatabase(HOST, DBNAME, OnConnected); 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); - } -} +RegressionTestHarness.FrameTickUntilComplete(db, () => applied && waiting == 0, TIMEOUT); Log.Info("Success"); Environment.Exit(0); diff --git a/sdks/csharp/examples~/regression-tests/procedure-client/client.csproj b/sdks/csharp/examples~/regression-tests/procedure-client/client.csproj index 590940fbcb0..04759b33920 100644 --- a/sdks/csharp/examples~/regression-tests/procedure-client/client.csproj +++ b/sdks/csharp/examples~/regression-tests/procedure-client/client.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/sdks/csharp/examples~/regression-tests/republishing/client/Program.cs b/sdks/csharp/examples~/regression-tests/republishing/client/Program.cs index 52931e7bf7d..e8493cee87f 100644 --- a/sdks/csharp/examples~/regression-tests/republishing/client/Program.cs +++ b/sdks/csharp/examples~/regression-tests/republishing/client/Program.cs @@ -5,38 +5,13 @@ using System.Diagnostics; using System.Runtime.CompilerServices; +using RegressionTests.Shared; using SpacetimeDB; using SpacetimeDB.Types; const string HOST = "http://localhost:3000"; const string DBNAME = "republish-test"; -DbConnection ConnectToDB() -{ - DbConnection? conn = null; - conn = DbConnection.Builder() - .WithUri(HOST) - .WithDatabaseName(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; bool applied = false; SubscriptionHandle? handle = null; @@ -128,24 +103,9 @@ void OnSubscriptionApplied(SubscriptionEventContext context) Log.Info("Evaluation of ExampleData in republishing test completed."); } -System.AppDomain.CurrentDomain.UnhandledException += (sender, args) => -{ - Log.Exception($"Unhandled exception: {sender} {args}"); - Environment.Exit(1); -}; -var db = ConnectToDB(); -Log.Info("Starting timer"); +RegressionTestHarness.RegisterUnhandledExceptionExitHandler(); +var db = RegressionTestHarness.ConnectToDatabase(HOST, DBNAME, OnConnected); 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); - } -} +RegressionTestHarness.FrameTickUntilComplete(db, () => applied && waiting == 0, TIMEOUT); Log.Info("Success"); Environment.Exit(0); diff --git a/sdks/csharp/examples~/regression-tests/republishing/client/client.csproj b/sdks/csharp/examples~/regression-tests/republishing/client/client.csproj index 759ea6f4a26..9c07c1d1c1b 100644 --- a/sdks/csharp/examples~/regression-tests/republishing/client/client.csproj +++ b/sdks/csharp/examples~/regression-tests/republishing/client/client.csproj @@ -11,4 +11,8 @@ + + + + diff --git a/sdks/csharp/examples~/regression-tests/shared/RegressionTestHarness.cs b/sdks/csharp/examples~/regression-tests/shared/RegressionTestHarness.cs new file mode 100644 index 00000000000..b81563ff4a5 --- /dev/null +++ b/sdks/csharp/examples~/regression-tests/shared/RegressionTestHarness.cs @@ -0,0 +1,206 @@ +using System.Threading; +using SpacetimeDB; +using SpacetimeDB.Types; + +namespace RegressionTests.Shared; + +internal static class RegressionTestHarness +{ + public static void RegisterUnhandledExceptionExitHandler() + { + AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => + { + Log.Exception($"Unhandled exception: {eventArgs.ExceptionObject}"); + Environment.Exit(1); + }; + } + + public static void RunNamedTests(string[] args, IReadOnlyDictionary tests) + { + if (args.Length > 1) + { + throw new ArgumentException("Pass zero args (run all) or a single test name."); + } + + if (args.Length == 1) + { + var testName = args[0]; + if (!tests.TryGetValue(testName, out var test)) + { + throw new ArgumentException($"Unknown test: {testName}"); + } + + Log.Info($"Running {testName}"); + test(); + return; + } + + foreach (var (testName, test) in tests) + { + Log.Info($"Running {testName}"); + test(); + } + } + + public static DbConnection ConnectToDatabase( + string host, + string databaseName, + DbConnectionBuilder.ConnectCallback onConnect, + Action? onConnectError = null, + Action? onDisconnect = null + ) + { + return DbConnection + .Builder() + .WithUri(host) + .WithDatabaseName(databaseName) + .OnConnect(onConnect) + .OnConnectError(err => + { + if (onConnectError != null) + { + onConnectError(err); + return; + } + + throw err; + }) + .OnDisconnect((_, err) => + { + if (onDisconnect != null) + { + onDisconnect(err); + return; + } + + if (err != null) + { + throw err; + } + + throw new Exception("Unexpected disconnect"); + }) + .Build(); + } + + public static void RunLiveConnectionTest( + string host, + string databaseName, + string testName, + int timeoutSeconds, + Action> start + ) + { + bool complete = false; + bool disconnectExpected = false; + Exception? failure = null; + + void Pass() => complete = true; + void Fail(Exception error) => failure ??= error; + + var conn = ConnectToDatabase( + host, + databaseName, + (connected, _, _) => + { + try + { + start(connected, Pass, Fail); + } + catch (Exception ex) + { + Fail(ex); + } + }, + onConnectError: Fail, + onDisconnect: err => + { + if (disconnectExpected) + { + return; + } + + if (err != null) + { + Fail(err); + return; + } + + if (!complete) + { + Fail(new Exception($"Unexpected disconnect in {testName}")); + } + } + ); + + FrameTickUntilComplete( + conn, + () => complete || failure != null, + timeoutSeconds, + sleepMilliseconds: 10, + logStart: false + ); + + disconnectExpected = true; + if (conn.IsActive) + { + conn.Disconnect(); + } + + if (failure != null) + { + throw new Exception($"{testName} failed", failure); + } + } + + public static void FrameTickUntilComplete( + DbConnection conn, + Func isComplete, + int timeoutSeconds, + int sleepMilliseconds = 100, + bool logStart = true + ) + { + if (logStart) + { + Log.Info("Starting timer"); + } + + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (!isComplete()) + { + conn.FrameTick(); + Thread.Sleep(sleepMilliseconds); + if (DateTime.UtcNow > deadline) + { + Log.Error($"Timeout, all events should have elapsed in {timeoutSeconds} seconds!"); + Environment.Exit(1); + } + } + } + + public static void Require(bool condition, string message) + { + if (!condition) + { + throw new Exception(message); + } + } + + public static void AssertReducerCommitted(string reducerName, ReducerEventContext ctx) + { + switch (ctx.Event.Status) + { + case Status.Committed: + return; + case Status.Failed(var reason): + throw new Exception($"`{reducerName}` reducer returned error: {reason}"); + case Status.OutOfEnergy(var _): + throw new Exception($"`{reducerName}` reducer ran out of energy"); + default: + throw new Exception( + $"`{reducerName}` reducer returned unexpected status: {ctx.Event.Status}" + ); + } + } +} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/Program.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/Program.cs index 04521c89439..7e914265a47 100644 --- a/sdks/csharp/examples~/regression-tests/view-pk-client/Program.cs +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/Program.cs @@ -2,6 +2,7 @@ /// To run these, start a local SpacetimeDB via `spacetime start`, /// publish `modules/sdk-test-view-pk-cs`, and then run this client. using System.Threading; +using RegressionTests.Shared; using SpacetimeDB; using SpacetimeDB.Types; @@ -20,143 +21,60 @@ ExecViewPkSemijoinTwoSenderViewsQueryBuilder, }; -if (args.Length > 1) -{ - throw new ArgumentException("Pass zero args (run all) or a single test name."); -} - -System.AppDomain.CurrentDomain.UnhandledException += (sender, evt) => -{ - Log.Exception($"Unhandled exception: {sender} {evt}"); - Environment.Exit(1); -}; +RegressionTestHarness.RegisterUnhandledExceptionExitHandler(); +RegressionTestHarness.RunNamedTests(args, tests); +Log.Info("Success"); +Environment.Exit(0); -if (args.Length == 1) +void RunViewPkTest(string testName, Action> start) { - var testName = args[0]; - if (!tests.TryGetValue(testName, out var test)) - { - throw new ArgumentException($"Unknown test: {testName}"); - } - - Log.Info($"Running {testName}"); - test(); + RegressionTestHarness.RunLiveConnectionTest(HOST, DBNAME, testName, TIMEOUT_SECONDS, start); } -else + +void RunOrFail(Action work, Action fail) { - foreach (var (testName, test) in tests) + try { - Log.Info($"Running {testName}"); - test(); + work(); } -} - -Log.Info("Success"); -Environment.Exit(0); - -void Expect(bool condition, string message) -{ - if (!condition) + catch (Exception ex) { - throw new Exception(message); + fail(ex); } } -void AssertReducerCommitted(string reducerName, ReducerEventContext ctx) +void AssertCommittedOrFail(string reducerName, ReducerEventContext ctx, Action fail) { - switch (ctx.Event.Status) - { - case Status.Committed: - return; - case Status.Failed(var reason): - throw new Exception($"`{reducerName}` reducer returned error: {reason}"); - case Status.OutOfEnergy(var _): - throw new Exception($"`{reducerName}` reducer ran out of energy"); - default: - throw new Exception($"`{reducerName}` reducer returned unexpected status: {ctx.Event.Status}"); - } + RunOrFail(() => RegressionTestHarness.AssertReducerCommitted(reducerName, ctx), fail); } -void RunViewPkTest( +void ExpectSinglePlayerUpdate( string testName, - Action> start + ref bool sawUpdate, + ulong expectedId, + string expectedOldName, + string expectedNewName, + ulong oldId, + string oldName, + ulong newId, + string newName ) { - bool complete = false; - bool disconnectExpected = false; - Exception? failure = null; - - void Pass() - { - complete = true; - } - - void Fail(Exception error) - { - failure ??= error; - } - - var conn = DbConnection - .Builder() - .WithUri(HOST) - .WithDatabaseName(DBNAME) - .OnConnect((connected, _, _) => - { - try - { - start(connected, Pass, Fail); - } - catch (Exception ex) - { - Fail(ex); - } - }) - .OnConnectError(err => - { - Fail(err); - }) - .OnDisconnect((_, err) => - { - if (disconnectExpected) - { - return; - } - - if (err != null) - { - Fail(err); - return; - } - - if (!complete) - { - Fail(new Exception($"Unexpected disconnect in {testName}")); - } - }) - .Build(); - - var deadline = DateTime.UtcNow.AddSeconds(TIMEOUT_SECONDS); - while (!complete && failure == null) - { - conn.FrameTick(); - Thread.Sleep(10); - - if (DateTime.UtcNow > deadline) - { - throw new TimeoutException($"Timeout waiting for {testName}"); - } - } - - disconnectExpected = true; - if (conn.IsActive) - { - conn.Disconnect(); - } - - if (failure != null) - { - throw new Exception($"{testName} failed", failure); - } + RegressionTestHarness.Require( + !sawUpdate, + $"Expected exactly one OnUpdate callback for {testName}." + ); + RegressionTestHarness.Require(oldId == expectedId, $"Expected oldRow.Id={expectedId}, got {oldId}."); + RegressionTestHarness.Require( + oldName == expectedOldName, + $"Expected oldRow.Name={expectedOldName}, got {oldName}." + ); + RegressionTestHarness.Require(newId == expectedId, $"Expected newRow.Id={expectedId}, got {newId}."); + RegressionTestHarness.Require( + newName == expectedNewName, + $"Expected newRow.Name={expectedNewName}, got {newName}." + ); + sawUpdate = true; } /// Subscribe to a query builder view whose underlying table has a primary key. @@ -173,70 +91,52 @@ void Fail(Exception error) /// - `newRow` should be the "after" value void ExecViewPkOnUpdate() { + const string testName = "view-pk-on-update"; var playerId = NextId(); const string before = "before"; const string after = "after"; - RunViewPkTest("view-pk-on-update", (conn, pass, fail) => + RunViewPkTest(testName, (conn, pass, fail) => { bool sawUpdate = false; conn.Reducers.OnInsertViewPkPlayer += (ctx, _, _) => - { - try - { - AssertReducerCommitted("insert_view_pk_player", ctx); - } - catch (Exception ex) - { - fail(ex); - } - }; - + AssertCommittedOrFail("insert_view_pk_player", ctx, fail); conn.Reducers.OnUpdateViewPkPlayer += (ctx, _, _) => - { - try - { - AssertReducerCommitted("update_view_pk_player", ctx); - } - catch (Exception ex) - { - fail(ex); - } - }; + AssertCommittedOrFail("update_view_pk_player", ctx, fail); conn .SubscriptionBuilder() .OnApplied(ctx => - { - try - { - ctx.Db.AllViewPkPlayers.OnUpdate += (_, oldRow, newRow) => + RunOrFail( + () => { - try - { - Expect(!sawUpdate, "Expected exactly one OnUpdate callback for view-pk-on-update."); - Expect(oldRow.Id == playerId, $"Expected oldRow.Id={playerId}, got {oldRow.Id}."); - Expect(oldRow.Name == before, $"Expected oldRow.Name={before}, got {oldRow.Name}."); - Expect(newRow.Id == playerId, $"Expected newRow.Id={playerId}, got {newRow.Id}."); - Expect(newRow.Name == after, $"Expected newRow.Name={after}, got {newRow.Name}."); - sawUpdate = true; - pass(); - } - catch (Exception ex) - { - fail(ex); - } - }; + ctx.Db.AllViewPkPlayers.OnUpdate += (_, oldRow, newRow) => + RunOrFail( + () => + { + ExpectSinglePlayerUpdate( + testName, + ref sawUpdate, + playerId, + before, + after, + oldRow.Id, + oldRow.Name, + newRow.Id, + newRow.Name + ); + pass(); + }, + fail + ); - ctx.Reducers.InsertViewPkPlayer(playerId, before); - ctx.Reducers.UpdateViewPkPlayer(playerId, after); - } - catch (Exception ex) - { - fail(ex); - } - }) + ctx.Reducers.InsertViewPkPlayer(playerId, before); + ctx.Reducers.UpdateViewPkPlayer(playerId, after); + }, + fail + ) + ) .OnError((_, err) => fail(err)) .Subscribe(["SELECT * FROM all_view_pk_players"]); }); @@ -264,50 +164,22 @@ void ExecViewPkOnUpdate() /// - `newRow` should be the "after" value void ExecViewPkJoinQueryBuilder() { + const string testName = "view-pk-join-query-builder"; var playerId = NextId(); var membershipId = NextId(); const string before = "before"; const string after = "after"; - RunViewPkTest("view-pk-join-query-builder", (conn, pass, fail) => + RunViewPkTest(testName, (conn, pass, fail) => { bool sawUpdate = false; conn.Reducers.OnInsertViewPkPlayer += (ctx, _, _) => - { - try - { - AssertReducerCommitted("insert_view_pk_player", ctx); - } - catch (Exception ex) - { - fail(ex); - } - }; - + AssertCommittedOrFail("insert_view_pk_player", ctx, fail); conn.Reducers.OnInsertViewPkMembership += (ctx, _, _) => - { - try - { - AssertReducerCommitted("insert_view_pk_membership", ctx); - } - catch (Exception ex) - { - fail(ex); - } - }; - + AssertCommittedOrFail("insert_view_pk_membership", ctx, fail); conn.Reducers.OnUpdateViewPkPlayer += (ctx, _, _) => - { - try - { - AssertReducerCommitted("update_view_pk_player", ctx); - } - catch (Exception ex) - { - fail(ex); - } - }; + AssertCommittedOrFail("update_view_pk_player", ctx, fail); conn .SubscriptionBuilder() @@ -318,36 +190,36 @@ void ExecViewPkJoinQueryBuilder() ) ) .OnApplied(ctx => - { - try - { - ctx.Db.AllViewPkPlayers.OnUpdate += (_, oldRow, newRow) => + RunOrFail( + () => { - try - { - Expect(!sawUpdate, "Expected exactly one OnUpdate callback for view-pk-join-query-builder."); - Expect(oldRow.Id == playerId, $"Expected oldRow.Id={playerId}, got {oldRow.Id}."); - Expect(oldRow.Name == before, $"Expected oldRow.Name={before}, got {oldRow.Name}."); - Expect(newRow.Id == playerId, $"Expected newRow.Id={playerId}, got {newRow.Id}."); - Expect(newRow.Name == after, $"Expected newRow.Name={after}, got {newRow.Name}."); - sawUpdate = true; - pass(); - } - catch (Exception ex) - { - fail(ex); - } - }; + ctx.Db.AllViewPkPlayers.OnUpdate += (_, oldRow, newRow) => + RunOrFail( + () => + { + ExpectSinglePlayerUpdate( + testName, + ref sawUpdate, + playerId, + before, + after, + oldRow.Id, + oldRow.Name, + newRow.Id, + newRow.Name + ); + pass(); + }, + fail + ); - ctx.Reducers.InsertViewPkPlayer(playerId, before); - ctx.Reducers.InsertViewPkMembership(membershipId, playerId); - ctx.Reducers.UpdateViewPkPlayer(playerId, after); - } - catch (Exception ex) - { - fail(ex); - } - }) + ctx.Reducers.InsertViewPkPlayer(playerId, before); + ctx.Reducers.InsertViewPkMembership(membershipId, playerId); + ctx.Reducers.UpdateViewPkPlayer(playerId, after); + }, + fail + ) + ) .OnError((_, err) => fail(err)) .Subscribe(); }); @@ -376,108 +248,67 @@ void ExecViewPkJoinQueryBuilder() /// - `newRow` should be the "after" value void ExecViewPkSemijoinTwoSenderViewsQueryBuilder() { + const string testName = "view-pk-semijoin-two-sender-views-query-builder"; var playerId = NextId(); var membershipAId = NextId(); var membershipBId = NextId(); const string before = "before"; const string after = "after"; - RunViewPkTest( - "view-pk-semijoin-two-sender-views-query-builder", - (conn, pass, fail) => - { - bool sawUpdate = false; - - conn.Reducers.OnInsertViewPkPlayer += (ctx, _, _) => - { - try - { - AssertReducerCommitted("insert_view_pk_player", ctx); - } - catch (Exception ex) - { - fail(ex); - } - }; - - conn.Reducers.OnInsertViewPkMembership += (ctx, _, _) => - { - try - { - AssertReducerCommitted("insert_view_pk_membership", ctx); - } - catch (Exception ex) - { - fail(ex); - } - }; - - conn.Reducers.OnInsertViewPkMembershipSecondary += (ctx, _, _) => - { - try - { - AssertReducerCommitted("insert_view_pk_membership_secondary", ctx); - } - catch (Exception ex) - { - fail(ex); - } - }; + RunViewPkTest(testName, (conn, pass, fail) => + { + bool sawUpdate = false; - conn.Reducers.OnUpdateViewPkPlayer += (ctx, _, _) => - { - try - { - AssertReducerCommitted("update_view_pk_player", ctx); - } - catch (Exception ex) - { - fail(ex); - } - }; + conn.Reducers.OnInsertViewPkPlayer += (ctx, _, _) => + AssertCommittedOrFail("insert_view_pk_player", ctx, fail); + conn.Reducers.OnInsertViewPkMembership += (ctx, _, _) => + AssertCommittedOrFail("insert_view_pk_membership", ctx, fail); + conn.Reducers.OnInsertViewPkMembershipSecondary += (ctx, _, _) => + AssertCommittedOrFail("insert_view_pk_membership_secondary", ctx, fail); + conn.Reducers.OnUpdateViewPkPlayer += (ctx, _, _) => + AssertCommittedOrFail("update_view_pk_player", ctx, fail); - conn - .SubscriptionBuilder() - .AddQuery(q => - q.From.SenderViewPkPlayersA().RightSemijoin( - q.From.SenderViewPkPlayersB(), - (lhsView, rhsView) => lhsView.Id.Eq(rhsView.Id) - ) + conn + .SubscriptionBuilder() + .AddQuery(q => + q.From.SenderViewPkPlayersA().RightSemijoin( + q.From.SenderViewPkPlayersB(), + (lhsView, rhsView) => lhsView.Id.Eq(rhsView.Id) ) - .OnApplied(ctx => - { - try + ) + .OnApplied(ctx => + RunOrFail( + () => { ctx.Db.SenderViewPkPlayersB.OnUpdate += (_, oldRow, newRow) => - { - try - { - Expect(!sawUpdate, "Expected exactly one OnUpdate callback for view-pk-semijoin-two-sender-views-query-builder."); - Expect(oldRow.Id == playerId, $"Expected oldRow.Id={playerId}, got {oldRow.Id}."); - Expect(oldRow.Name == before, $"Expected oldRow.Name={before}, got {oldRow.Name}."); - Expect(newRow.Id == playerId, $"Expected newRow.Id={playerId}, got {newRow.Id}."); - Expect(newRow.Name == after, $"Expected newRow.Name={after}, got {newRow.Name}."); - sawUpdate = true; - pass(); - } - catch (Exception ex) - { - fail(ex); - } - }; + RunOrFail( + () => + { + ExpectSinglePlayerUpdate( + testName, + ref sawUpdate, + playerId, + before, + after, + oldRow.Id, + oldRow.Name, + newRow.Id, + newRow.Name + ); + pass(); + }, + fail + ); ctx.Reducers.InsertViewPkPlayer(playerId, before); ctx.Reducers.InsertViewPkMembership(membershipAId, playerId); ctx.Reducers.InsertViewPkMembershipSecondary(membershipBId, playerId); ctx.Reducers.UpdateViewPkPlayer(playerId, after); - } - catch (Exception ex) - { - fail(ex); - } - }) - .OnError((_, err) => fail(err)) - .Subscribe(); - } - ); + }, + fail + ) + ) + .OnError((_, err) => fail(err)) + .Subscribe(); + }); } diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/client.csproj b/sdks/csharp/examples~/regression-tests/view-pk-client/client.csproj index c0e1682bcbc..bf7c38298e1 100644 --- a/sdks/csharp/examples~/regression-tests/view-pk-client/client.csproj +++ b/sdks/csharp/examples~/regression-tests/view-pk-client/client.csproj @@ -12,4 +12,8 @@ + + + + From fd7d9e697cfc9a20ccff28bbee663231637066a6 Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Thu, 12 Mar 2026 15:49:50 -0700 Subject: [PATCH 7/8] remove view-pk-client --- .../regression-tests/client/Program.cs | 268 +++++++- .../Reducers/InsertViewPkMembership.g.cs | 0 .../InsertViewPkMembershipSecondary.g.cs | 0 .../Reducers/InsertViewPkPlayer.g.cs | 0 .../Reducers/UpdateViewPkPlayer.g.cs | 0 .../module_bindings/SpacetimeDBClient.g.cs | 24 +- .../Tables/AllViewPkPlayers.g.cs | 0 .../Tables/SenderViewPkPlayersA.g.cs | 0 .../Tables/SenderViewPkPlayersB.g.cs | 0 .../Tables/ViewPkMembership.g.cs | 0 .../Tables/ViewPkMembershipSecondary.g.cs | 0 .../module_bindings/Tables/ViewPkPlayer.g.cs | 0 .../Types/ViewPkMembership.g.cs | 0 .../Types/ViewPkMembershipSecondary.g.cs | 0 .../module_bindings/Types/ViewPkPlayer.g.cs | 0 .../examples~/regression-tests/server/Lib.cs | 87 +++ .../view-pk-client/Program.cs | 314 --------- .../view-pk-client/client.csproj | 19 - .../module_bindings/SpacetimeDBClient.g.cs | 646 ------------------ sdks/csharp/tools~/gen-regression-tests.sh | 1 - sdks/csharp/tools~/run-regression-tests.sh | 10 +- 21 files changed, 371 insertions(+), 998 deletions(-) rename sdks/csharp/examples~/regression-tests/{view-pk-client => client}/module_bindings/Reducers/InsertViewPkMembership.g.cs (100%) rename sdks/csharp/examples~/regression-tests/{view-pk-client => client}/module_bindings/Reducers/InsertViewPkMembershipSecondary.g.cs (100%) rename sdks/csharp/examples~/regression-tests/{view-pk-client => client}/module_bindings/Reducers/InsertViewPkPlayer.g.cs (100%) rename sdks/csharp/examples~/regression-tests/{view-pk-client => client}/module_bindings/Reducers/UpdateViewPkPlayer.g.cs (100%) rename sdks/csharp/examples~/regression-tests/{view-pk-client => client}/module_bindings/Tables/AllViewPkPlayers.g.cs (100%) rename sdks/csharp/examples~/regression-tests/{view-pk-client => client}/module_bindings/Tables/SenderViewPkPlayersA.g.cs (100%) rename sdks/csharp/examples~/regression-tests/{view-pk-client => client}/module_bindings/Tables/SenderViewPkPlayersB.g.cs (100%) rename sdks/csharp/examples~/regression-tests/{view-pk-client => client}/module_bindings/Tables/ViewPkMembership.g.cs (100%) rename sdks/csharp/examples~/regression-tests/{view-pk-client => client}/module_bindings/Tables/ViewPkMembershipSecondary.g.cs (100%) rename sdks/csharp/examples~/regression-tests/{view-pk-client => client}/module_bindings/Tables/ViewPkPlayer.g.cs (100%) rename sdks/csharp/examples~/regression-tests/{view-pk-client => client}/module_bindings/Types/ViewPkMembership.g.cs (100%) rename sdks/csharp/examples~/regression-tests/{view-pk-client => client}/module_bindings/Types/ViewPkMembershipSecondary.g.cs (100%) rename sdks/csharp/examples~/regression-tests/{view-pk-client => client}/module_bindings/Types/ViewPkPlayer.g.cs (100%) delete mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/Program.cs delete mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/client.csproj delete mode 100644 sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/SpacetimeDBClient.g.cs diff --git a/sdks/csharp/examples~/regression-tests/client/Program.cs b/sdks/csharp/examples~/regression-tests/client/Program.cs index c6fc59619c4..4ae9ddf46b5 100644 --- a/sdks/csharp/examples~/regression-tests/client/Program.cs +++ b/sdks/csharp/examples~/regression-tests/client/Program.cs @@ -20,15 +20,19 @@ const string EXPECTED_TEST_EVENT_NAME = "hello"; const ulong EXPECTED_TEST_EVENT_VALUE = 42; +DbConnection db = null!; uint waiting = 0; -var applied = false; -SubscriptionHandle? handle = null; +var runComplete = false; +SubscriptionHandle? mainHandle = null; uint testEventInsertCount = 0; +long viewPkIdCounter = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 10; + +ulong NextViewPkId() => (ulong)Interlocked.Increment(ref viewPkIdCounter); void OnConnected(DbConnection conn, Identity identity, string authToken) { Log.Debug($"Connected to {DBNAME} on {HOST}"); - handle = conn.SubscriptionBuilder() + mainHandle = conn.SubscriptionBuilder() .OnApplied(OnSubscriptionApplied) .OnError( (ctx, err) => @@ -255,6 +259,34 @@ string name "Event table iterator should remain empty after noop" ); }; + + conn.Reducers.OnInsertViewPkPlayer += (ctx, _, _) => + { + Log.Info("Got InsertViewPkPlayer callback"); + waiting--; + ValidateCommittedReducer("InsertViewPkPlayer", ctx); + }; + + conn.Reducers.OnUpdateViewPkPlayer += (ctx, _, _) => + { + Log.Info("Got UpdateViewPkPlayer callback"); + waiting--; + ValidateCommittedReducer("UpdateViewPkPlayer", ctx); + }; + + conn.Reducers.OnInsertViewPkMembership += (ctx, _, _) => + { + Log.Info("Got InsertViewPkMembership callback"); + waiting--; + ValidateCommittedReducer("InsertViewPkMembership", ctx); + }; + + conn.Reducers.OnInsertViewPkMembershipSecondary += (ctx, _, _) => + { + Log.Info("Got InsertViewPkMembershipSecondary callback"); + waiting--; + ValidateCommittedReducer("InsertViewPkMembershipSecondary", ctx); + }; } const uint MAX_ID = 10; @@ -511,10 +543,229 @@ void ValidateSemijoinSubscriptions(IRemoteDbContext conn, Identity identity) Debug.Assert(levels[0].Level == 1, $"Expected player_level.Level == 1, got {levels[0].Level}"); } -void OnSubscriptionApplied(SubscriptionEventContext context) +void ValidateCommittedReducer(string reducerName, ReducerEventContext ctx) +{ + switch (ctx.Event.Status) + { + case Status.Committed: + return; + case Status.Failed(var reason): + throw new Exception($"{reducerName} should commit, got failure: {reason}"); + case Status.OutOfEnergy(var _): + throw new Exception($"{reducerName} ran out of energy"); + default: + throw new Exception($"{reducerName} returned unexpected status: {ctx.Event.Status}"); + } +} + +void ExpectSingleViewPkPlayerUpdate( + string testName, + ref bool sawUpdate, + ulong expectedId, + string expectedOldName, + string expectedNewName, + ViewPkPlayer oldRow, + ViewPkPlayer newRow +) +{ + Debug.Assert(!sawUpdate, $"Expected exactly one OnUpdate callback for {testName}"); + Debug.Assert(oldRow.Id == expectedId, $"Expected oldRow.Id={expectedId}, got {oldRow.Id}"); + Debug.Assert( + oldRow.Name == expectedOldName, + $"Expected oldRow.Name={expectedOldName}, got {oldRow.Name}" + ); + Debug.Assert(newRow.Id == expectedId, $"Expected newRow.Id={expectedId}, got {newRow.Id}"); + Debug.Assert( + newRow.Name == expectedNewName, + $"Expected newRow.Name={expectedNewName}, got {newRow.Name}" + ); + sawUpdate = true; +} + +void StartViewPkOnUpdatePhase() +{ + const string testName = "view-pk-on-update"; + var playerId = NextViewPkId(); + const string before = "before"; + const string after = "after"; + bool sawUpdate = false; + SubscriptionHandle? phaseHandle = null; + + Log.Debug($"Starting {testName}"); + phaseHandle = db + .SubscriptionBuilder() + .OnApplied(ctx => + { + void OnAllViewPkPlayersUpdate(EventContext _, ViewPkPlayer oldRow, ViewPkPlayer newRow) + { + ctx.Db.AllViewPkPlayers.OnUpdate -= OnAllViewPkPlayersUpdate; + ExpectSingleViewPkPlayerUpdate( + testName, + ref sawUpdate, + playerId, + before, + after, + oldRow, + newRow + ); + + waiting++; + phaseHandle?.UnsubscribeThen(_ => + { + Debug.Assert(sawUpdate, $"Expected an OnUpdate callback for {testName}"); + StartViewPkJoinPhase(); + waiting--; + }); + } + + ctx.Db.AllViewPkPlayers.OnUpdate += OnAllViewPkPlayersUpdate; + + waiting++; + ctx.Reducers.InsertViewPkPlayer(playerId, before); + + waiting++; + ctx.Reducers.UpdateViewPkPlayer(playerId, after); + }) + .OnError((_, err) => + { + throw err; + }) + .AddQuery(q => q.From.AllViewPkPlayers()) + .Subscribe(); +} + +void StartViewPkJoinPhase() { - applied = true; + const string testName = "view-pk-join-query-builder"; + var playerId = NextViewPkId(); + var membershipId = NextViewPkId(); + const string before = "before"; + const string after = "after"; + bool sawUpdate = false; + SubscriptionHandle? phaseHandle = null; + + Log.Debug($"Starting {testName}"); + phaseHandle = db + .SubscriptionBuilder() + .OnApplied(ctx => + { + void OnAllViewPkPlayersUpdate(EventContext _, ViewPkPlayer oldRow, ViewPkPlayer newRow) + { + ctx.Db.AllViewPkPlayers.OnUpdate -= OnAllViewPkPlayersUpdate; + ExpectSingleViewPkPlayerUpdate( + testName, + ref sawUpdate, + playerId, + before, + after, + oldRow, + newRow + ); + + waiting++; + phaseHandle?.UnsubscribeThen(_ => + { + Debug.Assert(sawUpdate, $"Expected an OnUpdate callback for {testName}"); + StartViewPkSemijoinTwoSenderViewsPhase(); + waiting--; + }); + } + ctx.Db.AllViewPkPlayers.OnUpdate += OnAllViewPkPlayersUpdate; + + waiting++; + ctx.Reducers.InsertViewPkPlayer(playerId, before); + + waiting++; + ctx.Reducers.InsertViewPkMembership(membershipId, playerId); + + waiting++; + ctx.Reducers.UpdateViewPkPlayer(playerId, after); + }) + .OnError((_, err) => + { + throw err; + }) + .AddQuery(q => + q.From.ViewPkMembership().RightSemijoin( + q.From.AllViewPkPlayers(), + (membership, player) => membership.PlayerId.Eq(player.Id) + ) + ) + .Subscribe(); +} + +void StartViewPkSemijoinTwoSenderViewsPhase() +{ + const string testName = "view-pk-semijoin-two-sender-views-query-builder"; + var playerId = NextViewPkId(); + var membershipAId = NextViewPkId(); + var membershipBId = NextViewPkId(); + const string before = "before"; + const string after = "after"; + bool sawUpdate = false; + SubscriptionHandle? phaseHandle = null; + + Log.Debug($"Starting {testName}"); + phaseHandle = db + .SubscriptionBuilder() + .OnApplied(ctx => + { + void OnSenderViewPkPlayersBUpdate( + EventContext _, + ViewPkPlayer oldRow, + ViewPkPlayer newRow + ) + { + ctx.Db.SenderViewPkPlayersB.OnUpdate -= OnSenderViewPkPlayersBUpdate; + ExpectSingleViewPkPlayerUpdate( + testName, + ref sawUpdate, + playerId, + before, + after, + oldRow, + newRow + ); + + waiting++; + phaseHandle?.UnsubscribeThen(_ => + { + Debug.Assert(sawUpdate, $"Expected an OnUpdate callback for {testName}"); + runComplete = true; + waiting--; + }); + } + + ctx.Db.SenderViewPkPlayersB.OnUpdate += OnSenderViewPkPlayersBUpdate; + + waiting++; + ctx.Reducers.InsertViewPkPlayer(playerId, before); + + waiting++; + ctx.Reducers.InsertViewPkMembership(membershipAId, playerId); + + waiting++; + ctx.Reducers.InsertViewPkMembershipSecondary(membershipBId, playerId); + + waiting++; + ctx.Reducers.UpdateViewPkPlayer(playerId, after); + }) + .OnError((_, err) => + { + throw err; + }) + .AddQuery(q => + q.From.SenderViewPkPlayersA().RightSemijoin( + q.From.SenderViewPkPlayersB(), + (lhsView, rhsView) => lhsView.Id.Eq(rhsView.Id) + ) + ) + .Subscribe(); +} + +void OnSubscriptionApplied(SubscriptionEventContext context) +{ ValidateWhereSubscription(context); ValidateWhereTestViews(context); ValidateSemijoinSubscriptions(context, context.Identity!.Value); @@ -1089,19 +1340,20 @@ ProcedureCallbackResult> result // Now unsubscribe and check that the unsubscribing is actually applied. Log.Debug("Calling Unsubscribe"); waiting++; - handle?.UnsubscribeThen( + mainHandle?.UnsubscribeThen( (ctx) => { Log.Debug("Received Unsubscribe"); ValidateBTreeIndexes(ctx); + StartViewPkOnUpdatePhase(); waiting--; } ); } RegressionTestHarness.RegisterUnhandledExceptionExitHandler(); -var db = RegressionTestHarness.ConnectToDatabase(HOST, DBNAME, OnConnected); +db = RegressionTestHarness.ConnectToDatabase(HOST, DBNAME, OnConnected); const int TIMEOUT = 20; // seconds; -RegressionTestHarness.FrameTickUntilComplete(db, () => applied && waiting == 0, TIMEOUT); +RegressionTestHarness.FrameTickUntilComplete(db, () => runComplete && waiting == 0, TIMEOUT); Log.Info("Success"); Environment.Exit(0); diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkMembership.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/InsertViewPkMembership.g.cs similarity index 100% rename from sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkMembership.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/InsertViewPkMembership.g.cs diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkMembershipSecondary.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/InsertViewPkMembershipSecondary.g.cs similarity index 100% rename from sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkMembershipSecondary.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/InsertViewPkMembershipSecondary.g.cs diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkPlayer.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/InsertViewPkPlayer.g.cs similarity index 100% rename from sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/InsertViewPkPlayer.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/InsertViewPkPlayer.g.cs diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/UpdateViewPkPlayer.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/UpdateViewPkPlayer.g.cs similarity index 100% rename from sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Reducers/UpdateViewPkPlayer.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Reducers/UpdateViewPkPlayer.g.cs diff --git a/sdks/csharp/examples~/regression-tests/client/module_bindings/SpacetimeDBClient.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/SpacetimeDBClient.g.cs index 52b4bb8deb7..480000e349d 100644 --- a/sdks/csharp/examples~/regression-tests/client/module_bindings/SpacetimeDBClient.g.cs +++ b/sdks/csharp/examples~/regression-tests/client/module_bindings/SpacetimeDBClient.g.cs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.4 (commit dfc726be29516b8cdecc651f5c9705026a624a04). +// This was generated using spacetimedb cli version 2.0.4 (commit 589c48dc67d151edb98f2e4a5245a7b4fac70767). #nullable enable @@ -29,6 +29,7 @@ public RemoteTables(DbConnection conn) { AddTable(Admins = new(conn)); AddTable(Account = new(conn)); + AddTable(AllViewPkPlayers = new(conn)); AddTable(ExampleData = new(conn)); AddTable(FindWhereTest = new(conn)); AddTable(MyAccount = new(conn)); @@ -48,12 +49,17 @@ public RemoteTables(DbConnection conn) AddTable(ScoresPlayer123 = new(conn)); AddTable(ScoresPlayer123Level5 = new(conn)); AddTable(ScoresPlayer123Range = new(conn)); + AddTable(SenderViewPkPlayersA = new(conn)); + AddTable(SenderViewPkPlayersB = new(conn)); AddTable(TestEvent = new(conn)); AddTable(User = new(conn)); AddTable(UsersAge1865 = new(conn)); AddTable(UsersAge18Plus = new(conn)); AddTable(UsersAgeUnder18 = new(conn)); AddTable(UsersNamedAlice = new(conn)); + AddTable(ViewPkMembership = new(conn)); + AddTable(ViewPkMembershipSecondary = new(conn)); + AddTable(ViewPkPlayer = new(conn)); AddTable(WhereTest = new(conn)); AddTable(WhereTestQuery = new(conn)); AddTable(WhereTestView = new(conn)); @@ -555,6 +561,7 @@ public sealed class QueryBuilder { new QueryBuilder().From.Admins().ToSql(), new QueryBuilder().From.Account().ToSql(), + new QueryBuilder().From.AllViewPkPlayers().ToSql(), new QueryBuilder().From.ExampleData().ToSql(), new QueryBuilder().From.FindWhereTest().ToSql(), new QueryBuilder().From.MyAccount().ToSql(), @@ -574,12 +581,17 @@ public sealed class QueryBuilder new QueryBuilder().From.ScoresPlayer123().ToSql(), new QueryBuilder().From.ScoresPlayer123Level5().ToSql(), new QueryBuilder().From.ScoresPlayer123Range().ToSql(), + new QueryBuilder().From.SenderViewPkPlayersA().ToSql(), + new QueryBuilder().From.SenderViewPkPlayersB().ToSql(), new QueryBuilder().From.TestEvent().ToSql(), new QueryBuilder().From.User().ToSql(), new QueryBuilder().From.UsersAge1865().ToSql(), new QueryBuilder().From.UsersAge18Plus().ToSql(), new QueryBuilder().From.UsersAgeUnder18().ToSql(), new QueryBuilder().From.UsersNamedAlice().ToSql(), + new QueryBuilder().From.ViewPkMembership().ToSql(), + new QueryBuilder().From.ViewPkMembershipSecondary().ToSql(), + new QueryBuilder().From.ViewPkPlayer().ToSql(), new QueryBuilder().From.WhereTest().ToSql(), new QueryBuilder().From.WhereTestQuery().ToSql(), new QueryBuilder().From.WhereTestView().ToSql(), @@ -591,6 +603,7 @@ public sealed class From { public global::SpacetimeDB.Table Admins() => new("admins", new AdminsCols("admins"), new AdminsIxCols("admins")); public global::SpacetimeDB.Table Account() => new("account", new AccountCols("account"), new AccountIxCols("account")); + public global::SpacetimeDB.Table AllViewPkPlayers() => new("all_view_pk_players", new AllViewPkPlayersCols("all_view_pk_players"), new AllViewPkPlayersIxCols("all_view_pk_players")); public global::SpacetimeDB.Table ExampleData() => new("example_data", new ExampleDataCols("example_data"), new ExampleDataIxCols("example_data")); public global::SpacetimeDB.Table FindWhereTest() => new("find_where_test", new FindWhereTestCols("find_where_test"), new FindWhereTestIxCols("find_where_test")); public global::SpacetimeDB.Table MyAccount() => new("my_account", new MyAccountCols("my_account"), new MyAccountIxCols("my_account")); @@ -610,12 +623,17 @@ public sealed class From public global::SpacetimeDB.Table ScoresPlayer123() => new("scores_player_123", new ScoresPlayer123Cols("scores_player_123"), new ScoresPlayer123IxCols("scores_player_123")); public global::SpacetimeDB.Table ScoresPlayer123Level5() => new("scores_player_123_level_5", new ScoresPlayer123Level5Cols("scores_player_123_level_5"), new ScoresPlayer123Level5IxCols("scores_player_123_level_5")); public global::SpacetimeDB.Table ScoresPlayer123Range() => new("scores_player_123_range", new ScoresPlayer123RangeCols("scores_player_123_range"), new ScoresPlayer123RangeIxCols("scores_player_123_range")); + public global::SpacetimeDB.Table SenderViewPkPlayersA() => new("sender_view_pk_players_a", new SenderViewPkPlayersACols("sender_view_pk_players_a"), new SenderViewPkPlayersAIxCols("sender_view_pk_players_a")); + public global::SpacetimeDB.Table SenderViewPkPlayersB() => new("sender_view_pk_players_b", new SenderViewPkPlayersBCols("sender_view_pk_players_b"), new SenderViewPkPlayersBIxCols("sender_view_pk_players_b")); public global::SpacetimeDB.Table TestEvent() => new("test_event", new TestEventCols("test_event"), new TestEventIxCols("test_event")); public global::SpacetimeDB.Table User() => new("user", new UserCols("user"), new UserIxCols("user")); public global::SpacetimeDB.Table UsersAge1865() => new("users_age_18_65", new UsersAge1865Cols("users_age_18_65"), new UsersAge1865IxCols("users_age_18_65")); public global::SpacetimeDB.Table UsersAge18Plus() => new("users_age_18_plus", new UsersAge18PlusCols("users_age_18_plus"), new UsersAge18PlusIxCols("users_age_18_plus")); public global::SpacetimeDB.Table UsersAgeUnder18() => new("users_age_under_18", new UsersAgeUnder18Cols("users_age_under_18"), new UsersAgeUnder18IxCols("users_age_under_18")); public global::SpacetimeDB.Table UsersNamedAlice() => new("users_named_alice", new UsersNamedAliceCols("users_named_alice"), new UsersNamedAliceIxCols("users_named_alice")); + public global::SpacetimeDB.Table ViewPkMembership() => new("view_pk_membership", new ViewPkMembershipCols("view_pk_membership"), new ViewPkMembershipIxCols("view_pk_membership")); + public global::SpacetimeDB.Table ViewPkMembershipSecondary() => new("view_pk_membership_secondary", new ViewPkMembershipSecondaryCols("view_pk_membership_secondary"), new ViewPkMembershipSecondaryIxCols("view_pk_membership_secondary")); + public global::SpacetimeDB.Table ViewPkPlayer() => new("view_pk_player", new ViewPkPlayerCols("view_pk_player"), new ViewPkPlayerIxCols("view_pk_player")); public global::SpacetimeDB.Table WhereTest() => new("where_test", new WhereTestCols("where_test"), new WhereTestIxCols("where_test")); public global::SpacetimeDB.Table WhereTestQuery() => new("where_test_query", new WhereTestQueryCols("where_test_query"), new WhereTestQueryIxCols("where_test_query")); public global::SpacetimeDB.Table WhereTestView() => new("where_test_view", new WhereTestViewCols("where_test_view"), new WhereTestViewIxCols("where_test_view")); @@ -707,10 +725,14 @@ protected override bool Dispatch(IReducerEventContext context, Reducer reducer) Reducer.InsertNullStringIntoNonNullable args => Reducers.InvokeInsertNullStringIntoNonNullable(eventContext, args), Reducer.InsertNullStringIntoNullable args => Reducers.InvokeInsertNullStringIntoNullable(eventContext, args), Reducer.InsertResult args => Reducers.InvokeInsertResult(eventContext, args), + Reducer.InsertViewPkMembership args => Reducers.InvokeInsertViewPkMembership(eventContext, args), + Reducer.InsertViewPkMembershipSecondary args => Reducers.InvokeInsertViewPkMembershipSecondary(eventContext, args), + Reducer.InsertViewPkPlayer args => Reducers.InvokeInsertViewPkPlayer(eventContext, args), Reducer.InsertWhereTest args => Reducers.InvokeInsertWhereTest(eventContext, args), Reducer.Noop args => Reducers.InvokeNoop(eventContext, args), Reducer.SetNullableVec args => Reducers.InvokeSetNullableVec(eventContext, args), Reducer.ThrowError args => Reducers.InvokeThrowError(eventContext, args), + Reducer.UpdateViewPkPlayer args => Reducers.InvokeUpdateViewPkPlayer(eventContext, args), Reducer.UpdateWhereTest args => Reducers.InvokeUpdateWhereTest(eventContext, args), _ => throw new ArgumentOutOfRangeException("Reducer", $"Unknown reducer {reducer}") }; diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/AllViewPkPlayers.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/AllViewPkPlayers.g.cs similarity index 100% rename from sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/AllViewPkPlayers.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/AllViewPkPlayers.g.cs diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/SenderViewPkPlayersA.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/SenderViewPkPlayersA.g.cs similarity index 100% rename from sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/SenderViewPkPlayersA.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/SenderViewPkPlayersA.g.cs diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/SenderViewPkPlayersB.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/SenderViewPkPlayersB.g.cs similarity index 100% rename from sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/SenderViewPkPlayersB.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/SenderViewPkPlayersB.g.cs diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkMembership.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/ViewPkMembership.g.cs similarity index 100% rename from sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkMembership.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/ViewPkMembership.g.cs diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkMembershipSecondary.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/ViewPkMembershipSecondary.g.cs similarity index 100% rename from sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkMembershipSecondary.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/ViewPkMembershipSecondary.g.cs diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkPlayer.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/ViewPkPlayer.g.cs similarity index 100% rename from sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Tables/ViewPkPlayer.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Tables/ViewPkPlayer.g.cs diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkMembership.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Types/ViewPkMembership.g.cs similarity index 100% rename from sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkMembership.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Types/ViewPkMembership.g.cs diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkMembershipSecondary.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Types/ViewPkMembershipSecondary.g.cs similarity index 100% rename from sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkMembershipSecondary.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Types/ViewPkMembershipSecondary.g.cs diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkPlayer.g.cs b/sdks/csharp/examples~/regression-tests/client/module_bindings/Types/ViewPkPlayer.g.cs similarity index 100% rename from sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/Types/ViewPkPlayer.g.cs rename to sdks/csharp/examples~/regression-tests/client/module_bindings/Types/ViewPkPlayer.g.cs diff --git a/sdks/csharp/examples~/regression-tests/server/Lib.cs b/sdks/csharp/examples~/regression-tests/server/Lib.cs index 67f57eef047..39b1a881efe 100644 --- a/sdks/csharp/examples~/regression-tests/server/Lib.cs +++ b/sdks/csharp/examples~/regression-tests/server/Lib.cs @@ -169,6 +169,35 @@ public partial struct NullStringNullable public string? Name; } + [SpacetimeDB.Table(Accessor = "view_pk_player", Public = true)] + public partial struct ViewPkPlayer + { + [SpacetimeDB.PrimaryKey] + public ulong Id; + + public string Name; + } + + [SpacetimeDB.Table(Accessor = "view_pk_membership", Public = true)] + public partial struct ViewPkMembership + { + [SpacetimeDB.PrimaryKey] + public ulong Id; + + [SpacetimeDB.Index.BTree] + public ulong PlayerId; + } + + [SpacetimeDB.Table(Accessor = "view_pk_membership_secondary", Public = true)] + public partial struct ViewPkMembershipSecondary + { + [SpacetimeDB.PrimaryKey] + public ulong Id; + + [SpacetimeDB.Index.BTree] + public ulong PlayerId; + } + // At-most-one row: return T? [SpacetimeDB.View(Accessor = "my_player", Public = true)] public static Player? MyPlayer(ViewContext ctx) @@ -335,6 +364,34 @@ public static List NullableVecView(AnonymousViewContext ctx) return rows; } + [SpacetimeDB.View(Accessor = "all_view_pk_players", Public = true)] + public static IQuery AllViewPkPlayers(ViewContext ctx) + { + return ctx.From.view_pk_player(); + } + + [SpacetimeDB.View(Accessor = "sender_view_pk_players_a", Public = true)] + public static IQuery SenderViewPkPlayersA(ViewContext ctx) + { + return ctx + .From.view_pk_membership() + .RightSemijoin( + ctx.From.view_pk_player(), + (membership, player) => membership.PlayerId.Eq(player.Id) + ); + } + + [SpacetimeDB.View(Accessor = "sender_view_pk_players_b", Public = true)] + public static IQuery SenderViewPkPlayersB(ViewContext ctx) + { + return ctx + .From.view_pk_membership_secondary() + .RightSemijoin( + ctx.From.view_pk_player(), + (membership, player) => membership.PlayerId.Eq(player.Id) + ); + } + [SpacetimeDB.Reducer] public static void Delete(ReducerContext ctx, uint id) { @@ -424,6 +481,36 @@ public static void UpdateWhereTest(ReducerContext ctx, uint id, uint value, stri ); } + [SpacetimeDB.Reducer] + public static void InsertViewPkPlayer(ReducerContext ctx, ulong id, string name) + { + ctx.Db.view_pk_player.Insert(new ViewPkPlayer { Id = id, Name = name }); + } + + [SpacetimeDB.Reducer] + public static void UpdateViewPkPlayer(ReducerContext ctx, ulong id, string name) + { + ctx.Db.view_pk_player.Id.Update(new ViewPkPlayer { Id = id, Name = name }); + } + + [SpacetimeDB.Reducer] + public static void InsertViewPkMembership(ReducerContext ctx, ulong id, ulong playerId) + { + ctx.Db.view_pk_membership.Insert(new ViewPkMembership { Id = id, PlayerId = playerId }); + } + + [SpacetimeDB.Reducer] + public static void InsertViewPkMembershipSecondary( + ReducerContext ctx, + ulong id, + ulong playerId + ) + { + ctx.Db.view_pk_membership_secondary.Insert( + new ViewPkMembershipSecondary { Id = id, PlayerId = playerId } + ); + } + [Reducer(ReducerKind.ClientConnected)] public static void ClientConnected(ReducerContext ctx) { diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/Program.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/Program.cs deleted file mode 100644 index 7e914265a47..00000000000 --- a/sdks/csharp/examples~/regression-tests/view-pk-client/Program.cs +++ /dev/null @@ -1,314 +0,0 @@ -/// View-PK regression tests run with a live server. -/// To run these, start a local SpacetimeDB via `spacetime start`, -/// publish `modules/sdk-test-view-pk-cs`, and then run this client. -using System.Threading; -using RegressionTests.Shared; -using SpacetimeDB; -using SpacetimeDB.Types; - -const string HOST = "http://localhost:3000"; -const string DBNAME = "view-pk-tests"; -const int TIMEOUT_SECONDS = 20; - -long idCounter = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 10; -ulong NextId() => (ulong)Interlocked.Increment(ref idCounter); - -var tests = new Dictionary(StringComparer.Ordinal) -{ - ["view-pk-on-update"] = ExecViewPkOnUpdate, - ["view-pk-join-query-builder"] = ExecViewPkJoinQueryBuilder, - ["view-pk-semijoin-two-sender-views-query-builder"] = - ExecViewPkSemijoinTwoSenderViewsQueryBuilder, -}; - -RegressionTestHarness.RegisterUnhandledExceptionExitHandler(); -RegressionTestHarness.RunNamedTests(args, tests); -Log.Info("Success"); -Environment.Exit(0); - -void RunViewPkTest(string testName, Action> start) -{ - RegressionTestHarness.RunLiveConnectionTest(HOST, DBNAME, testName, TIMEOUT_SECONDS, start); -} - -void RunOrFail(Action work, Action fail) -{ - try - { - work(); - } - catch (Exception ex) - { - fail(ex); - } -} - -void AssertCommittedOrFail(string reducerName, ReducerEventContext ctx, Action fail) -{ - RunOrFail(() => RegressionTestHarness.AssertReducerCommitted(reducerName, ctx), fail); -} - -void ExpectSinglePlayerUpdate( - string testName, - ref bool sawUpdate, - ulong expectedId, - string expectedOldName, - string expectedNewName, - ulong oldId, - string oldName, - ulong newId, - string newName -) -{ - RegressionTestHarness.Require( - !sawUpdate, - $"Expected exactly one OnUpdate callback for {testName}." - ); - RegressionTestHarness.Require(oldId == expectedId, $"Expected oldRow.Id={expectedId}, got {oldId}."); - RegressionTestHarness.Require( - oldName == expectedOldName, - $"Expected oldRow.Name={expectedOldName}, got {oldName}." - ); - RegressionTestHarness.Require(newId == expectedId, $"Expected newRow.Id={expectedId}, got {newId}."); - RegressionTestHarness.Require( - newName == expectedNewName, - $"Expected newRow.Name={expectedNewName}, got {newName}." - ); - sawUpdate = true; -} - -/// Subscribe to a query builder view whose underlying table has a primary key. -/// Ensures the C# SDK emits an `OnUpdate` callback and that the client receives the correct old and new rows. -/// -/// Test: -/// 1. Subscribe to: SELECT * FROM all_view_pk_players -/// 2. Insert row: (id=1, name="before") -/// 3. Update row: (id=1, name="after") -/// -/// Expect: -/// - `OnUpdate` is called for PK=1 -/// - `oldRow` should be the "before" value -/// - `newRow` should be the "after" value -void ExecViewPkOnUpdate() -{ - const string testName = "view-pk-on-update"; - var playerId = NextId(); - const string before = "before"; - const string after = "after"; - - RunViewPkTest(testName, (conn, pass, fail) => - { - bool sawUpdate = false; - - conn.Reducers.OnInsertViewPkPlayer += (ctx, _, _) => - AssertCommittedOrFail("insert_view_pk_player", ctx, fail); - conn.Reducers.OnUpdateViewPkPlayer += (ctx, _, _) => - AssertCommittedOrFail("update_view_pk_player", ctx, fail); - - conn - .SubscriptionBuilder() - .OnApplied(ctx => - RunOrFail( - () => - { - ctx.Db.AllViewPkPlayers.OnUpdate += (_, oldRow, newRow) => - RunOrFail( - () => - { - ExpectSinglePlayerUpdate( - testName, - ref sawUpdate, - playerId, - before, - after, - oldRow.Id, - oldRow.Name, - newRow.Id, - newRow.Name - ); - pass(); - }, - fail - ); - - ctx.Reducers.InsertViewPkPlayer(playerId, before); - ctx.Reducers.UpdateViewPkPlayer(playerId, after); - }, - fail - ) - ) - .OnError((_, err) => fail(err)) - .Subscribe(["SELECT * FROM all_view_pk_players"]); - }); -} - -/// Subscribe to a right semijoin whose rhs is a view with primary key. -/// -/// Ensures: -/// 1. A semijoin subscription involving a view is valid -/// 2. The C# SDK emits an `OnUpdate` callback and that the client receives the correct old and new rows -/// -/// Query: -/// SELECT player.* -/// FROM view_pk_membership membership -/// JOIN all_view_pk_players player ON membership.player_id = player.id -/// -/// Test: -/// 1. Insert player row (id=1, "before"). -/// 2. Insert membership row referencing player_id=1, allowing the semijoin match. -/// 3. Update player row to (id=1, "after"). -/// -/// Expect: -/// - `OnUpdate` is called for player PK=1 -/// - `oldRow` should be the "before" value -/// - `newRow` should be the "after" value -void ExecViewPkJoinQueryBuilder() -{ - const string testName = "view-pk-join-query-builder"; - var playerId = NextId(); - var membershipId = NextId(); - const string before = "before"; - const string after = "after"; - - RunViewPkTest(testName, (conn, pass, fail) => - { - bool sawUpdate = false; - - conn.Reducers.OnInsertViewPkPlayer += (ctx, _, _) => - AssertCommittedOrFail("insert_view_pk_player", ctx, fail); - conn.Reducers.OnInsertViewPkMembership += (ctx, _, _) => - AssertCommittedOrFail("insert_view_pk_membership", ctx, fail); - conn.Reducers.OnUpdateViewPkPlayer += (ctx, _, _) => - AssertCommittedOrFail("update_view_pk_player", ctx, fail); - - conn - .SubscriptionBuilder() - .AddQuery(q => - q.From.ViewPkMembership().RightSemijoin( - q.From.AllViewPkPlayers(), - (membership, player) => membership.PlayerId.Eq(player.Id) - ) - ) - .OnApplied(ctx => - RunOrFail( - () => - { - ctx.Db.AllViewPkPlayers.OnUpdate += (_, oldRow, newRow) => - RunOrFail( - () => - { - ExpectSinglePlayerUpdate( - testName, - ref sawUpdate, - playerId, - before, - after, - oldRow.Id, - oldRow.Name, - newRow.Id, - newRow.Name - ); - pass(); - }, - fail - ); - - ctx.Reducers.InsertViewPkPlayer(playerId, before); - ctx.Reducers.InsertViewPkMembership(membershipId, playerId); - ctx.Reducers.UpdateViewPkPlayer(playerId, after); - }, - fail - ) - ) - .OnError((_, err) => fail(err)) - .Subscribe(); - }); -} - -/// Subscribe to a semijoin between two views with primary keys. -/// -/// Ensures: -/// 1. A semijoin subscription involving a view is valid -/// 2. The C# SDK emits an `OnUpdate` callback and that the client receives the correct old and new rows -/// -/// Query: -/// SELECT b.* -/// FROM sender_view_pk_players_a a -/// JOIN sender_view_pk_players_b b ON a.id = b.id -/// -/// Test: -/// 1. Insert player row (id=1, "before"). -/// 2. Insert membership for sender view A. -/// 3. Insert membership for sender view B. -/// 4. Update player row to (id=1, "after"). -/// -/// Expect: -/// - `OnUpdate` is called for player PK=1 -/// - `oldRow` should be the "before" value -/// - `newRow` should be the "after" value -void ExecViewPkSemijoinTwoSenderViewsQueryBuilder() -{ - const string testName = "view-pk-semijoin-two-sender-views-query-builder"; - var playerId = NextId(); - var membershipAId = NextId(); - var membershipBId = NextId(); - const string before = "before"; - const string after = "after"; - - RunViewPkTest(testName, (conn, pass, fail) => - { - bool sawUpdate = false; - - conn.Reducers.OnInsertViewPkPlayer += (ctx, _, _) => - AssertCommittedOrFail("insert_view_pk_player", ctx, fail); - conn.Reducers.OnInsertViewPkMembership += (ctx, _, _) => - AssertCommittedOrFail("insert_view_pk_membership", ctx, fail); - conn.Reducers.OnInsertViewPkMembershipSecondary += (ctx, _, _) => - AssertCommittedOrFail("insert_view_pk_membership_secondary", ctx, fail); - conn.Reducers.OnUpdateViewPkPlayer += (ctx, _, _) => - AssertCommittedOrFail("update_view_pk_player", ctx, fail); - - conn - .SubscriptionBuilder() - .AddQuery(q => - q.From.SenderViewPkPlayersA().RightSemijoin( - q.From.SenderViewPkPlayersB(), - (lhsView, rhsView) => lhsView.Id.Eq(rhsView.Id) - ) - ) - .OnApplied(ctx => - RunOrFail( - () => - { - ctx.Db.SenderViewPkPlayersB.OnUpdate += (_, oldRow, newRow) => - RunOrFail( - () => - { - ExpectSinglePlayerUpdate( - testName, - ref sawUpdate, - playerId, - before, - after, - oldRow.Id, - oldRow.Name, - newRow.Id, - newRow.Name - ); - pass(); - }, - fail - ); - - ctx.Reducers.InsertViewPkPlayer(playerId, before); - ctx.Reducers.InsertViewPkMembership(membershipAId, playerId); - ctx.Reducers.InsertViewPkMembershipSecondary(membershipBId, playerId); - ctx.Reducers.UpdateViewPkPlayer(playerId, after); - }, - fail - ) - ) - .OnError((_, err) => fail(err)) - .Subscribe(); - }); -} diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/client.csproj b/sdks/csharp/examples~/regression-tests/view-pk-client/client.csproj deleted file mode 100644 index bf7c38298e1..00000000000 --- a/sdks/csharp/examples~/regression-tests/view-pk-client/client.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - Exe - net8.0 - enable - enable - true - - - - - - - - - - - diff --git a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/SpacetimeDBClient.g.cs b/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/SpacetimeDBClient.g.cs deleted file mode 100644 index a43949b6a86..00000000000 --- a/sdks/csharp/examples~/regression-tests/view-pk-client/module_bindings/SpacetimeDBClient.g.cs +++ /dev/null @@ -1,646 +0,0 @@ -// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE -// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. - -// This was generated using spacetimedb cli version 2.0.4 (commit dfc726be29516b8cdecc651f5c9705026a624a04). - -#nullable enable - -using System; -using SpacetimeDB.ClientApi; -using System.Collections.Generic; -using System.Runtime.Serialization; - -namespace SpacetimeDB.Types -{ - public sealed partial class RemoteReducers : RemoteBase - { - internal RemoteReducers(DbConnection conn) : base(conn) { } - internal event Action? InternalOnUnhandledReducerError; - } - - public sealed partial class RemoteProcedures : RemoteBase - { - internal RemoteProcedures(DbConnection conn) : base(conn) { } - } - - public sealed partial class RemoteTables : RemoteTablesBase - { - public RemoteTables(DbConnection conn) - { - AddTable(AllViewPkPlayers = new(conn)); - AddTable(SenderViewPkPlayersA = new(conn)); - AddTable(SenderViewPkPlayersB = new(conn)); - AddTable(ViewPkMembership = new(conn)); - AddTable(ViewPkMembershipSecondary = new(conn)); - AddTable(ViewPkPlayer = new(conn)); - } - } - - - public interface IRemoteDbContext : IDbContext - { - public event Action? OnUnhandledReducerError; - } - - public sealed class EventContext : IEventContext, IRemoteDbContext - { - private readonly DbConnection conn; - - /// - /// The event that caused this callback to run. - /// - public readonly Event Event; - - /// - /// Access to tables in the client cache, which stores a read-only replica of the remote database state. - /// - /// The returned DbView will have a method to access each table defined by the module. - /// - public RemoteTables Db => conn.Db; - /// - /// Access to reducers defined by the module. - /// - /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, - /// plus methods for adding and removing callbacks on each of those reducers. - /// - public RemoteReducers Reducers => conn.Reducers; - /// - /// Access to procedures defined by the module. - /// - /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, - /// with a callback for when the procedure completes and returns a value. - /// - public RemoteProcedures Procedures => conn.Procedures; - /// - /// Returns true if the connection is active, i.e. has not yet disconnected. - /// - public bool IsActive => conn.IsActive; - /// - /// Close the connection. - /// - /// Throws an error if the connection is already closed. - /// - public void Disconnect() - { - conn.Disconnect(); - } - /// - /// Start building a subscription. - /// - /// A builder-pattern constructor for subscribing to queries, - /// causing matching rows to be replicated into the client cache. - public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); - /// - /// Get the Identity of this connection. - /// - /// This method returns null if the connection was constructed anonymously - /// and we have not yet received our newly-generated Identity from the host. - /// - public Identity? Identity => conn.Identity; - /// - /// Get this connection's ConnectionId. - /// - public ConnectionId ConnectionId => conn.ConnectionId; - /// - /// Register a callback to be called when a reducer with no handler returns an error. - /// - public event Action? OnUnhandledReducerError - { - add => Reducers.InternalOnUnhandledReducerError += value; - remove => Reducers.InternalOnUnhandledReducerError -= value; - } - - internal EventContext(DbConnection conn, Event Event) - { - this.conn = conn; - this.Event = Event; - } - } - - public sealed class ReducerEventContext : IReducerEventContext, IRemoteDbContext - { - private readonly DbConnection conn; - /// - /// The reducer event that caused this callback to run. - /// - public readonly ReducerEvent Event; - - /// - /// Access to tables in the client cache, which stores a read-only replica of the remote database state. - /// - /// The returned DbView will have a method to access each table defined by the module. - /// - public RemoteTables Db => conn.Db; - /// - /// Access to reducers defined by the module. - /// - /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, - /// plus methods for adding and removing callbacks on each of those reducers. - /// - public RemoteReducers Reducers => conn.Reducers; - /// - /// Access to procedures defined by the module. - /// - /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, - /// with a callback for when the procedure completes and returns a value. - /// - public RemoteProcedures Procedures => conn.Procedures; - /// - /// Returns true if the connection is active, i.e. has not yet disconnected. - /// - public bool IsActive => conn.IsActive; - /// - /// Close the connection. - /// - /// Throws an error if the connection is already closed. - /// - public void Disconnect() - { - conn.Disconnect(); - } - /// - /// Start building a subscription. - /// - /// A builder-pattern constructor for subscribing to queries, - /// causing matching rows to be replicated into the client cache. - public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); - /// - /// Get the Identity of this connection. - /// - /// This method returns null if the connection was constructed anonymously - /// and we have not yet received our newly-generated Identity from the host. - /// - public Identity? Identity => conn.Identity; - /// - /// Get this connection's ConnectionId. - /// - public ConnectionId ConnectionId => conn.ConnectionId; - /// - /// Register a callback to be called when a reducer with no handler returns an error. - /// - public event Action? OnUnhandledReducerError - { - add => Reducers.InternalOnUnhandledReducerError += value; - remove => Reducers.InternalOnUnhandledReducerError -= value; - } - - internal ReducerEventContext(DbConnection conn, ReducerEvent reducerEvent) - { - this.conn = conn; - Event = reducerEvent; - } - } - - public sealed class ErrorContext : IErrorContext, IRemoteDbContext - { - private readonly DbConnection conn; - /// - /// The Exception that caused this error callback to be run. - /// - public readonly Exception Event; - Exception IErrorContext.Event - { - get - { - return Event; - } - } - - /// - /// Access to tables in the client cache, which stores a read-only replica of the remote database state. - /// - /// The returned DbView will have a method to access each table defined by the module. - /// - public RemoteTables Db => conn.Db; - /// - /// Access to reducers defined by the module. - /// - /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, - /// plus methods for adding and removing callbacks on each of those reducers. - /// - public RemoteReducers Reducers => conn.Reducers; - /// - /// Access to procedures defined by the module. - /// - /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, - /// with a callback for when the procedure completes and returns a value. - /// - public RemoteProcedures Procedures => conn.Procedures; - /// - /// Returns true if the connection is active, i.e. has not yet disconnected. - /// - public bool IsActive => conn.IsActive; - /// - /// Close the connection. - /// - /// Throws an error if the connection is already closed. - /// - public void Disconnect() - { - conn.Disconnect(); - } - /// - /// Start building a subscription. - /// - /// A builder-pattern constructor for subscribing to queries, - /// causing matching rows to be replicated into the client cache. - public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); - /// - /// Get the Identity of this connection. - /// - /// This method returns null if the connection was constructed anonymously - /// and we have not yet received our newly-generated Identity from the host. - /// - public Identity? Identity => conn.Identity; - /// - /// Get this connection's ConnectionId. - /// - public ConnectionId ConnectionId => conn.ConnectionId; - /// - /// Register a callback to be called when a reducer with no handler returns an error. - /// - public event Action? OnUnhandledReducerError - { - add => Reducers.InternalOnUnhandledReducerError += value; - remove => Reducers.InternalOnUnhandledReducerError -= value; - } - - internal ErrorContext(DbConnection conn, Exception error) - { - this.conn = conn; - Event = error; - } - } - - public sealed class SubscriptionEventContext : ISubscriptionEventContext, IRemoteDbContext - { - private readonly DbConnection conn; - - /// - /// Access to tables in the client cache, which stores a read-only replica of the remote database state. - /// - /// The returned DbView will have a method to access each table defined by the module. - /// - public RemoteTables Db => conn.Db; - /// - /// Access to reducers defined by the module. - /// - /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, - /// plus methods for adding and removing callbacks on each of those reducers. - /// - public RemoteReducers Reducers => conn.Reducers; - /// - /// Access to procedures defined by the module. - /// - /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, - /// with a callback for when the procedure completes and returns a value. - /// - public RemoteProcedures Procedures => conn.Procedures; - /// - /// Returns true if the connection is active, i.e. has not yet disconnected. - /// - public bool IsActive => conn.IsActive; - /// - /// Close the connection. - /// - /// Throws an error if the connection is already closed. - /// - public void Disconnect() - { - conn.Disconnect(); - } - /// - /// Start building a subscription. - /// - /// A builder-pattern constructor for subscribing to queries, - /// causing matching rows to be replicated into the client cache. - public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); - /// - /// Get the Identity of this connection. - /// - /// This method returns null if the connection was constructed anonymously - /// and we have not yet received our newly-generated Identity from the host. - /// - public Identity? Identity => conn.Identity; - /// - /// Get this connection's ConnectionId. - /// - public ConnectionId ConnectionId => conn.ConnectionId; - /// - /// Register a callback to be called when a reducer with no handler returns an error. - /// - public event Action? OnUnhandledReducerError - { - add => Reducers.InternalOnUnhandledReducerError += value; - remove => Reducers.InternalOnUnhandledReducerError -= value; - } - - internal SubscriptionEventContext(DbConnection conn) - { - this.conn = conn; - } - } - - public sealed class ProcedureEventContext : IProcedureEventContext, IRemoteDbContext - { - private readonly DbConnection conn; - /// - /// The procedure event that caused this callback to run. - /// - public readonly ProcedureEvent Event; - - /// - /// Access to tables in the client cache, which stores a read-only replica of the remote database state. - /// - /// The returned DbView will have a method to access each table defined by the module. - /// - public RemoteTables Db => conn.Db; - /// - /// Access to reducers defined by the module. - /// - /// The returned RemoteReducers will have a method to invoke each reducer defined by the module, - /// plus methods for adding and removing callbacks on each of those reducers. - /// - public RemoteReducers Reducers => conn.Reducers; - /// - /// Access to procedures defined by the module. - /// - /// The returned RemoteProcedures will have a method to invoke each procedure defined by the module, - /// with a callback for when the procedure completes and returns a value. - /// - public RemoteProcedures Procedures => conn.Procedures; - /// - /// Returns true if the connection is active, i.e. has not yet disconnected. - /// - public bool IsActive => conn.IsActive; - /// - /// Close the connection. - /// - /// Throws an error if the connection is already closed. - /// - public void Disconnect() - { - conn.Disconnect(); - } - /// - /// Start building a subscription. - /// - /// A builder-pattern constructor for subscribing to queries, - /// causing matching rows to be replicated into the client cache. - public SubscriptionBuilder SubscriptionBuilder() => conn.SubscriptionBuilder(); - /// - /// Get the Identity of this connection. - /// - /// This method returns null if the connection was constructed anonymously - /// and we have not yet received our newly-generated Identity from the host. - /// - public Identity? Identity => conn.Identity; - /// - /// Get this connection's ConnectionId. - /// - public ConnectionId ConnectionId => conn.ConnectionId; - /// - /// Register a callback to be called when a reducer with no handler returns an error. - /// - public event Action? OnUnhandledReducerError - { - add => Reducers.InternalOnUnhandledReducerError += value; - remove => Reducers.InternalOnUnhandledReducerError -= value; - } - - internal ProcedureEventContext(DbConnection conn, ProcedureEvent Event) - { - this.conn = conn; - this.Event = Event; - } - } - - /// - /// Builder-pattern constructor for subscription queries. - /// - public sealed class SubscriptionBuilder - { - private readonly IDbConnection conn; - - private event Action? Applied; - private event Action? Error; - - /// - /// Private API, use conn.SubscriptionBuilder() instead. - /// - public SubscriptionBuilder(IDbConnection conn) - { - this.conn = conn; - } - - /// - /// Register a callback to run when the subscription is applied. - /// - public SubscriptionBuilder OnApplied( - Action callback - ) - { - Applied += callback; - return this; - } - - /// - /// Register a callback to run when the subscription fails. - /// - /// Note that this callback may run either when attempting to apply the subscription, - /// in which case Self::on_applied will never run, - /// or later during the subscription's lifetime if the module's interface changes, - /// in which case Self::on_applied may have already run. - /// - public SubscriptionBuilder OnError( - Action callback - ) - { - Error += callback; - return this; - } - - /// - /// Add a typed query to this subscription. - /// - /// This is the entry point for building subscriptions without writing SQL by hand. - /// Once a typed query is added, only typed queries may follow (SQL and typed queries cannot be mixed). - /// - public TypedSubscriptionBuilder AddQuery( - Func> build - ) - { - var typed = new TypedSubscriptionBuilder(conn, Applied, Error); - return typed.AddQuery(build); - } - - /// - /// Subscribe to the following SQL queries. - /// - /// This method returns immediately, with the data not yet added to the DbConnection. - /// The provided callbacks will be invoked once the data is returned from the remote server. - /// Data from all the provided queries will be returned at the same time. - /// - /// See the SpacetimeDB SQL docs for more information on SQL syntax: - /// https://spacetimedb.com/docs/sql - /// - public SubscriptionHandle Subscribe( - string[] querySqls - ) => new(conn, Applied, Error, querySqls); - - /// - /// Subscribe to all rows from all tables. - /// - /// This method is intended as a convenience - /// for applications where client-side memory use and network bandwidth are not concerns. - /// Applications where these resources are a constraint - /// should register more precise queries via Self.Subscribe - /// in order to replicate only the subset of data which the client needs to function. - /// - /// This method should not be combined with Self.Subscribe on the same DbConnection. - /// A connection may either Self.Subscribe to particular queries, - /// or Self.SubscribeToAllTables, but not both. - /// Attempting to call Self.Subscribe - /// on a DbConnection that has previously used Self.SubscribeToAllTables, - /// or vice versa, may misbehave in any number of ways, - /// including dropping subscriptions, corrupting the client cache, or panicking. - /// - public SubscriptionHandle SubscribeToAllTables() => - new(conn, Applied, Error, QueryBuilder.AllTablesSqlQueries()); - } - - public sealed class SubscriptionHandle : SubscriptionHandleBase - { - /// - /// Internal API. Construct SubscriptionHandles using conn.SubscriptionBuilder. - /// - public SubscriptionHandle( - IDbConnection conn, - Action? onApplied, - Action? onError, - string[] querySqls - ) : base(conn, onApplied, onError, querySqls) - { } - } - - public sealed class QueryBuilder - { - public From From { get; } = new(); - - internal static string[] AllTablesSqlQueries() => new string[] - { - new QueryBuilder().From.AllViewPkPlayers().ToSql(), - new QueryBuilder().From.SenderViewPkPlayersA().ToSql(), - new QueryBuilder().From.SenderViewPkPlayersB().ToSql(), - new QueryBuilder().From.ViewPkMembership().ToSql(), - new QueryBuilder().From.ViewPkMembershipSecondary().ToSql(), - new QueryBuilder().From.ViewPkPlayer().ToSql(), - } - ; - } - - public sealed class From - { - public global::SpacetimeDB.Table AllViewPkPlayers() => new("all_view_pk_players", new AllViewPkPlayersCols("all_view_pk_players"), new AllViewPkPlayersIxCols("all_view_pk_players")); - public global::SpacetimeDB.Table SenderViewPkPlayersA() => new("sender_view_pk_players_a", new SenderViewPkPlayersACols("sender_view_pk_players_a"), new SenderViewPkPlayersAIxCols("sender_view_pk_players_a")); - public global::SpacetimeDB.Table SenderViewPkPlayersB() => new("sender_view_pk_players_b", new SenderViewPkPlayersBCols("sender_view_pk_players_b"), new SenderViewPkPlayersBIxCols("sender_view_pk_players_b")); - public global::SpacetimeDB.Table ViewPkMembership() => new("view_pk_membership", new ViewPkMembershipCols("view_pk_membership"), new ViewPkMembershipIxCols("view_pk_membership")); - public global::SpacetimeDB.Table ViewPkMembershipSecondary() => new("view_pk_membership_secondary", new ViewPkMembershipSecondaryCols("view_pk_membership_secondary"), new ViewPkMembershipSecondaryIxCols("view_pk_membership_secondary")); - public global::SpacetimeDB.Table ViewPkPlayer() => new("view_pk_player", new ViewPkPlayerCols("view_pk_player"), new ViewPkPlayerIxCols("view_pk_player")); - } - - public sealed class TypedSubscriptionBuilder - { - private readonly IDbConnection conn; - private Action? Applied; - private Action? Error; - private readonly List querySqls = new(); - - internal TypedSubscriptionBuilder(IDbConnection conn, Action? applied, Action? error) - { - this.conn = conn; - Applied = applied; - Error = error; - } - - public TypedSubscriptionBuilder OnApplied(Action callback) - { - Applied += callback; - return this; - } - - public TypedSubscriptionBuilder OnError(Action callback) - { - Error += callback; - return this; - } - - public TypedSubscriptionBuilder AddQuery(Func> build) - { - var qb = new QueryBuilder(); - querySqls.Add(build(qb).ToSql()); - return this; - } - - public SubscriptionHandle Subscribe() => new(conn, Applied, Error, querySqls.ToArray()); - } - - public abstract partial class Reducer - { - private Reducer() { } - } - - public abstract partial class Procedure - { - private Procedure() { } - } - - public sealed class DbConnection : DbConnectionBase - { - public override RemoteTables Db { get; } - public readonly RemoteReducers Reducers; - public readonly RemoteProcedures Procedures; - - public DbConnection() - { - Db = new(this); - Reducers = new(this); - Procedures = new(this); - } - - protected override IEventContext ToEventContext(Event Event) => - new EventContext(this, Event); - - protected override IReducerEventContext ToReducerEventContext(ReducerEvent reducerEvent) => - new ReducerEventContext(this, reducerEvent); - - protected override ISubscriptionEventContext MakeSubscriptionEventContext() => - new SubscriptionEventContext(this); - - protected override IErrorContext ToErrorContext(Exception exception) => - new ErrorContext(this, exception); - - protected override IProcedureEventContext ToProcedureEventContext(ProcedureEvent procedureEvent) => - new ProcedureEventContext(this, procedureEvent); - - protected override bool Dispatch(IReducerEventContext context, Reducer reducer) - { - var eventContext = (ReducerEventContext)context; - return reducer switch - { - Reducer.InsertViewPkMembership args => Reducers.InvokeInsertViewPkMembership(eventContext, args), - Reducer.InsertViewPkMembershipSecondary args => Reducers.InvokeInsertViewPkMembershipSecondary(eventContext, args), - Reducer.InsertViewPkPlayer args => Reducers.InvokeInsertViewPkPlayer(eventContext, args), - Reducer.UpdateViewPkPlayer args => Reducers.InvokeUpdateViewPkPlayer(eventContext, args), - _ => throw new ArgumentOutOfRangeException("Reducer", $"Unknown reducer {reducer}") - }; - } - - public SubscriptionBuilder SubscriptionBuilder() => new(this); - public event Action OnUnhandledReducerError - { - add => Reducers.InternalOnUnhandledReducerError += value; - remove => Reducers.InternalOnUnhandledReducerError -= value; - } - } -} diff --git a/sdks/csharp/tools~/gen-regression-tests.sh b/sdks/csharp/tools~/gen-regression-tests.sh index 451a2131a60..8936971cd5a 100755 --- a/sdks/csharp/tools~/gen-regression-tests.sh +++ b/sdks/csharp/tools~/gen-regression-tests.sh @@ -10,4 +10,3 @@ cargo build --manifest-path "$STDB_PATH/crates/standalone/Cargo.toml" cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- generate -y -l csharp -o "$SDK_PATH/examples~/regression-tests/client/module_bindings" --module-path "$SDK_PATH/examples~/regression-tests/server" cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- generate -y -l csharp -o "$SDK_PATH/examples~/regression-tests/republishing/client/module_bindings" --module-path "$SDK_PATH/examples~/regression-tests/republishing/server-republish" cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- generate -y -l csharp -o "$SDK_PATH/examples~/regression-tests/procedure-client/module_bindings" --module-path "$STDB_PATH/modules/sdk-test-procedure" -cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- generate -y -l csharp -o "$SDK_PATH/examples~/regression-tests/view-pk-client/module_bindings" --module-path "$STDB_PATH/modules/sdk-test-view-pk-cs" diff --git a/sdks/csharp/tools~/run-regression-tests.sh b/sdks/csharp/tools~/run-regression-tests.sh index 8ce171384cb..847409c9c32 100644 --- a/sdks/csharp/tools~/run-regression-tests.sh +++ b/sdks/csharp/tools~/run-regression-tests.sh @@ -23,19 +23,14 @@ cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" call --server local cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish --server local -p "$SDK_PATH/examples~/regression-tests/republishing/server-republish" --break-clients republish-test cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" call --server local republish-test insert 2 -echo "Cleanup obj~ folders generated in $SDK_PATH/examples~/regression-tests/procedure-client and $SDK_PATH/examples~/regression-tests/view-pk-client" +echo "Cleanup obj~ folders generated in $SDK_PATH/examples~/regression-tests/procedure-client" # There is a bug in the code generator that creates obj~ folders in the output directory using a Rust project. rm -rf "$SDK_PATH/examples~/regression-tests/procedure-client"/*/obj~ rm -rf "$SDK_PATH/examples~/regression-tests/procedure-client/module_bindings"/*/obj~ -rm -rf "$SDK_PATH/examples~/regression-tests/view-pk-client"/*/obj~ -rm -rf "$SDK_PATH/examples~/regression-tests/view-pk-client/module_bindings"/*/obj~ # Publish module for procedure tests cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -c -y --server local -p "$STDB_PATH/modules/sdk-test-procedure" procedure-tests -# Publish module for view-pk tests -cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -c -y --server local -p "$STDB_PATH/modules/sdk-test-view-pk-cs" view-pk-tests - # Run client for btree test cd "$SDK_PATH/examples~/regression-tests/client" && dotnet run -c Debug @@ -44,6 +39,3 @@ cd "$SDK_PATH/examples~/regression-tests/republishing/client" && dotnet run -c D # Run client for procedure test cd "$SDK_PATH/examples~/regression-tests/procedure-client" && dotnet run -c Debug - -# Run client for view-pk tests -cd "$SDK_PATH/examples~/regression-tests/view-pk-client" && dotnet run -c Debug From 48c4ee6f6896741a87f53634636052dfb995fd4e Mon Sep 17 00:00:00 2001 From: joshua-spacetime Date: Thu, 12 Mar 2026 16:36:31 -0700 Subject: [PATCH 8/8] clean up --- .../regression-tests/client/Program.cs | 65 +++++++++- .../shared/RegressionTestHarness.cs | 114 ------------------ 2 files changed, 59 insertions(+), 120 deletions(-) diff --git a/sdks/csharp/examples~/regression-tests/client/Program.cs b/sdks/csharp/examples~/regression-tests/client/Program.cs index 4ae9ddf46b5..edc89983e83 100644 --- a/sdks/csharp/examples~/regression-tests/client/Program.cs +++ b/sdks/csharp/examples~/regression-tests/client/Program.cs @@ -582,7 +582,19 @@ ViewPkPlayer newRow sawUpdate = true; } -void StartViewPkOnUpdatePhase() +/// Subscribe to a query builder view whose underlying table has a primary key. +/// Ensures the C# SDK emits an `OnUpdate` callback and that the client receives the correct old and new rows. +/// +/// Test: +/// 1. Subscribe to: SELECT * FROM all_view_pk_players +/// 2. Insert row: (id=1, name="before") +/// 3. Update row: (id=1, name="after") +/// +/// Expect: +/// - `OnUpdate` is called for PK=1 +/// - `oldRow` should be the "before" value +/// - `newRow` should be the "after" value +void ExecViewPkOnUpdate() { const string testName = "view-pk-on-update"; var playerId = NextViewPkId(); @@ -613,7 +625,7 @@ void OnAllViewPkPlayersUpdate(EventContext _, ViewPkPlayer oldRow, ViewPkPlayer phaseHandle?.UnsubscribeThen(_ => { Debug.Assert(sawUpdate, $"Expected an OnUpdate callback for {testName}"); - StartViewPkJoinPhase(); + ExecViewPkJoinQueryBuilder(); waiting--; }); } @@ -634,7 +646,27 @@ void OnAllViewPkPlayersUpdate(EventContext _, ViewPkPlayer oldRow, ViewPkPlayer .Subscribe(); } -void StartViewPkJoinPhase() +/// Subscribe to a right semijoin whose rhs is a view with primary key. +/// +/// Ensures: +/// 1. A semijoin subscription involving a view is valid +/// 2. The C# SDK emits an `OnUpdate` callback and that the client receives the correct old and new rows +/// +/// Query: +/// SELECT player.* +/// FROM view_pk_membership membership +/// JOIN all_view_pk_players player ON membership.player_id = player.id +/// +/// Test: +/// 1. Insert player row (id=1, "before"). +/// 2. Insert membership row referencing player_id=1, allowing the semijoin match. +/// 3. Update player row to (id=1, "after"). +/// +/// Expect: +/// - `OnUpdate` is called for player PK=1 +/// - `oldRow` should be the "before" value +/// - `newRow` should be the "after" value +void ExecViewPkJoinQueryBuilder() { const string testName = "view-pk-join-query-builder"; var playerId = NextViewPkId(); @@ -666,7 +698,7 @@ void OnAllViewPkPlayersUpdate(EventContext _, ViewPkPlayer oldRow, ViewPkPlayer phaseHandle?.UnsubscribeThen(_ => { Debug.Assert(sawUpdate, $"Expected an OnUpdate callback for {testName}"); - StartViewPkSemijoinTwoSenderViewsPhase(); + ExecViewPkSemijoinTwoSenderViewsQueryBuilder(); waiting--; }); } @@ -695,7 +727,28 @@ void OnAllViewPkPlayersUpdate(EventContext _, ViewPkPlayer oldRow, ViewPkPlayer .Subscribe(); } -void StartViewPkSemijoinTwoSenderViewsPhase() +/// Subscribe to a semijoin between two views with primary keys. +/// +/// Ensures: +/// 1. A semijoin subscription involving a view is valid +/// 2. The C# SDK emits an `OnUpdate` callback and that the client receives the correct old and new rows +/// +/// Query: +/// SELECT b.* +/// FROM sender_view_pk_players_a a +/// JOIN sender_view_pk_players_b b ON a.id = b.id +/// +/// Test: +/// 1. Insert player row (id=1, "before"). +/// 2. Insert membership for sender view A. +/// 3. Insert membership for sender view B. +/// 4. Update player row to (id=1, "after"). +/// +/// Expect: +/// - `OnUpdate` is called for player PK=1 +/// - `oldRow` should be the "before" value +/// - `newRow` should be the "after" value +void ExecViewPkSemijoinTwoSenderViewsQueryBuilder() { const string testName = "view-pk-semijoin-two-sender-views-query-builder"; var playerId = NextViewPkId(); @@ -1345,7 +1398,7 @@ ProcedureCallbackResult> result { Log.Debug("Received Unsubscribe"); ValidateBTreeIndexes(ctx); - StartViewPkOnUpdatePhase(); + ExecViewPkOnUpdate(); waiting--; } ); diff --git a/sdks/csharp/examples~/regression-tests/shared/RegressionTestHarness.cs b/sdks/csharp/examples~/regression-tests/shared/RegressionTestHarness.cs index b81563ff4a5..986c042e226 100644 --- a/sdks/csharp/examples~/regression-tests/shared/RegressionTestHarness.cs +++ b/sdks/csharp/examples~/regression-tests/shared/RegressionTestHarness.cs @@ -15,33 +15,6 @@ public static void RegisterUnhandledExceptionExitHandler() }; } - public static void RunNamedTests(string[] args, IReadOnlyDictionary tests) - { - if (args.Length > 1) - { - throw new ArgumentException("Pass zero args (run all) or a single test name."); - } - - if (args.Length == 1) - { - var testName = args[0]; - if (!tests.TryGetValue(testName, out var test)) - { - throw new ArgumentException($"Unknown test: {testName}"); - } - - Log.Info($"Running {testName}"); - test(); - return; - } - - foreach (var (testName, test) in tests) - { - Log.Info($"Running {testName}"); - test(); - } - } - public static DbConnection ConnectToDatabase( string host, string databaseName, @@ -83,76 +56,6 @@ public static DbConnection ConnectToDatabase( .Build(); } - public static void RunLiveConnectionTest( - string host, - string databaseName, - string testName, - int timeoutSeconds, - Action> start - ) - { - bool complete = false; - bool disconnectExpected = false; - Exception? failure = null; - - void Pass() => complete = true; - void Fail(Exception error) => failure ??= error; - - var conn = ConnectToDatabase( - host, - databaseName, - (connected, _, _) => - { - try - { - start(connected, Pass, Fail); - } - catch (Exception ex) - { - Fail(ex); - } - }, - onConnectError: Fail, - onDisconnect: err => - { - if (disconnectExpected) - { - return; - } - - if (err != null) - { - Fail(err); - return; - } - - if (!complete) - { - Fail(new Exception($"Unexpected disconnect in {testName}")); - } - } - ); - - FrameTickUntilComplete( - conn, - () => complete || failure != null, - timeoutSeconds, - sleepMilliseconds: 10, - logStart: false - ); - - disconnectExpected = true; - if (conn.IsActive) - { - conn.Disconnect(); - } - - if (failure != null) - { - throw new Exception($"{testName} failed", failure); - } - } - public static void FrameTickUntilComplete( DbConnection conn, Func isComplete, @@ -186,21 +89,4 @@ public static void Require(bool condition, string message) throw new Exception(message); } } - - public static void AssertReducerCommitted(string reducerName, ReducerEventContext ctx) - { - switch (ctx.Event.Status) - { - case Status.Committed: - return; - case Status.Failed(var reason): - throw new Exception($"`{reducerName}` reducer returned error: {reason}"); - case Status.OutOfEnergy(var _): - throw new Exception($"`{reducerName}` reducer ran out of energy"); - default: - throw new Exception( - $"`{reducerName}` reducer returned unexpected status: {ctx.Event.Status}" - ); - } - } }