mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-07-25 11:32:30 -04:00
a91be36596
# Description of Changes Adds primary keys to procedural views in C#. See for #5111 for the equivalent feature in rust and C# as well as a more detailed description. # API and ABI breaking changes None # Expected complexity level and risk 3 # Testing - [x] Equivalent tests as were added in #5111 for rust and typescript
1745 lines
62 KiB
C#
1745 lines
62 KiB
C#
/// tests run with a live server.
|
|
/// To run these, run a local SpacetimeDB via `spacetime start`,
|
|
/// then in a separate terminal run `tools~/run-regression-tests.sh PATH_TO_SPACETIMEDB_REPO_CHECKOUT`.
|
|
/// This is done on CI in .github/workflows/test.yml.
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading;
|
|
using RegressionTests.Shared;
|
|
using SpacetimeDB;
|
|
using SpacetimeDB.Types;
|
|
|
|
string HOST = Environment.GetEnvironmentVariable("SPACETIMEDB_SERVER_URL") ?? "http://localhost:3000";
|
|
const string DBNAME = "btree-repro";
|
|
const string THROW_ERROR_MESSAGE = "this is an error";
|
|
const uint UPDATED_WHERE_TEST_VALUE = 42;
|
|
const string UPDATED_WHERE_TEST_NAME = "this_name_was_updated";
|
|
const string EXPECTED_TEST_EVENT_NAME = "hello";
|
|
const ulong EXPECTED_TEST_EVENT_VALUE = 42;
|
|
|
|
DbConnection db = null!;
|
|
uint waiting = 0;
|
|
var runComplete = false;
|
|
var mainPhaseScheduled = false;
|
|
var mainUnsubscribeStarted = 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}");
|
|
mainHandle = conn.SubscriptionBuilder()
|
|
.OnApplied(OnSubscriptionApplied)
|
|
.OnError(
|
|
(ctx, err) =>
|
|
{
|
|
throw err;
|
|
}
|
|
)
|
|
.AddQuery(qb => qb.From.ExampleData())
|
|
.AddQuery(qb => qb.From.MyPlayer())
|
|
.AddQuery(qb => qb.From.MyAccount())
|
|
.AddQuery(qb => qb.From.MyAccountMissing())
|
|
.AddQuery(qb => qb.From.PlayersAtLevelOne())
|
|
.AddQuery(qb => qb.From.MyTable())
|
|
.AddQuery(qb => qb.From.NullStringNonnullable())
|
|
.AddQuery(qb => qb.From.NullStringNullable())
|
|
.AddQuery(qb => qb.From.MyLog())
|
|
.AddQuery(qb => qb.From.TestEvent())
|
|
.AddQuery(qb => qb.From.Admins())
|
|
.AddQuery(qb => qb.From.NullableVecView())
|
|
.AddQuery(qb => qb.From.WhereTest().Where(c => c.Value.Gt(10)))
|
|
.AddQuery(qb =>
|
|
qb.From.Player().LeftSemijoin(qb.From.PlayerLevel(), (p, pl) => p.Id.Eq(pl.PlayerId))
|
|
)
|
|
.AddQuery(qb =>
|
|
qb.From.Player()
|
|
.Where(c => c.Name.Eq("NewPlayer"))
|
|
.RightSemijoin(qb.From.PlayerLevel(), (p, pl) => p.Id.Eq(pl.PlayerId))
|
|
.Where(c => c.Level.Eq(1UL))
|
|
)
|
|
.AddQuery(qb => qb.From.UsersNamedAlice())
|
|
.AddQuery(qb => qb.From.UsersAge1865())
|
|
.AddQuery(qb => qb.From.UsersAge18Plus())
|
|
.AddQuery(qb => qb.From.UsersAgeUnder18())
|
|
.AddQuery(qb => qb.From.ScoresPlayer123())
|
|
.AddQuery(qb => qb.From.ScoresPlayer123Range())
|
|
.AddQuery(qb => qb.From.ScoresPlayer123Level5())
|
|
.AddQuery(qb =>
|
|
qb.From.User()
|
|
.Where(c => c.Age.Gte((byte)18).And(c.Age.Lt((byte)65)))
|
|
.Where(c => c.IsAdmin.Eq(true).Or(c.Name.Eq("Charlie")))
|
|
.Filter(c => c.Name.Neq("BOT"))
|
|
)
|
|
.AddQuery(qb => qb.From.Score())
|
|
.AddQuery(qb => qb.From.WhereTestView())
|
|
.AddQuery(qb => qb.From.FindWhereTest())
|
|
.AddQuery(qb => qb.From.WhereTestQuery())
|
|
.AddQuery(qb => qb.From.IenumerablePlayersFromIter())
|
|
.AddQuery(qb => qb.From.IenumerableAdminsFromFilter())
|
|
.AddQuery(qb => qb.From.IenumerablePlayersWithLevels())
|
|
.AddQuery(qb => qb.From.EqualityPerson())
|
|
.AddQuery(qb => qb.From.EqualityProduct())
|
|
.AddQuery(qb => qb.From.EqualityOrder())
|
|
.AddQuery(qb => qb.From.PlayerAction())
|
|
.AddQuery(qb => qb.From.ActionBatch())
|
|
.AddQuery(qb => qb.From.LogEntry())
|
|
.Subscribe();
|
|
|
|
// If testing against Rust, the indexed parameter will need to be changed to: ulong indexed
|
|
conn.Reducers.OnAdd += (ReducerEventContext ctx, uint id, uint indexed) =>
|
|
{
|
|
Log.Info("Got Add callback");
|
|
waiting--;
|
|
ValidateBTreeIndexes(ctx);
|
|
};
|
|
|
|
conn.Reducers.OnDelete += (ReducerEventContext ctx, uint id) =>
|
|
{
|
|
Log.Info("Got Delete callback");
|
|
waiting--;
|
|
ValidateBTreeIndexes(ctx);
|
|
};
|
|
|
|
conn.Reducers.OnInsertResult += (ReducerEventContext ctx, Result<MyTable, string> msg) =>
|
|
{
|
|
Log.Info($"Got InsertResult callback: {msg}");
|
|
waiting--;
|
|
};
|
|
|
|
conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception exception) =>
|
|
{
|
|
Log.Info($"Got OnUnhandledReducerError: {exception}");
|
|
waiting--;
|
|
ValidateReducerErrorDoesNotContainStackTrace(exception);
|
|
ValidateBTreeIndexes(ctx);
|
|
ValidateNullableVecView(ctx);
|
|
};
|
|
|
|
conn.Reducers.OnSetNullableVec += (
|
|
ReducerEventContext ctx,
|
|
uint id,
|
|
bool hasPos,
|
|
int x,
|
|
int y
|
|
) =>
|
|
{
|
|
Log.Info("Got SetNullableVec callback");
|
|
waiting--;
|
|
if (id == 1)
|
|
{
|
|
ValidateNullableVecView(ctx, hasPos, x, y);
|
|
}
|
|
else
|
|
{
|
|
ValidateNullableVecView(ctx);
|
|
}
|
|
};
|
|
|
|
conn.Reducers.OnInsertEmptyStringIntoNonNullable += (ReducerEventContext ctx) =>
|
|
{
|
|
Log.Info("Got InsertEmptyStringIntoNonNullable callback");
|
|
waiting--;
|
|
Debug.Assert(
|
|
ctx.Event.Status is Status.Committed,
|
|
$"InsertEmptyStringIntoNonNullable should commit, got {ctx.Event.Status}"
|
|
);
|
|
Debug.Assert(
|
|
ctx.Db.NullStringNonnullable.Iter().Any(r => r.Name == ""),
|
|
"Expected a row inserted into null_string_nonnullable with Name == \"\""
|
|
);
|
|
};
|
|
|
|
conn.Reducers.OnInsertNullStringIntoNonNullable += (ReducerEventContext ctx) =>
|
|
{
|
|
Log.Info("Got InsertNullStringIntoNonNullable callback");
|
|
waiting--;
|
|
|
|
if (ctx.Event.Status is Status.Failed(var reason))
|
|
{
|
|
Debug.Assert(
|
|
reason.Contains(
|
|
"Cannot serialize a null string",
|
|
StringComparison.OrdinalIgnoreCase
|
|
)
|
|
|| reason.Contains("BSATN", StringComparison.OrdinalIgnoreCase)
|
|
|| reason.Contains("nullable string", StringComparison.OrdinalIgnoreCase),
|
|
$"Expected a serialization-related failure message, got: {reason}"
|
|
);
|
|
}
|
|
else
|
|
{
|
|
throw new Exception(
|
|
$"InsertNullStringIntoNonNullable should fail, got status {ctx.Event.Status}"
|
|
);
|
|
}
|
|
};
|
|
|
|
conn.Reducers.OnInsertNullStringIntoNullable += (ReducerEventContext ctx) =>
|
|
{
|
|
Log.Info("Got InsertNullStringIntoNullable callback");
|
|
waiting--;
|
|
Debug.Assert(
|
|
ctx.Event.Status is Status.Committed,
|
|
$"InsertNullStringIntoNullable should commit, got {ctx.Event.Status}"
|
|
);
|
|
Debug.Assert(
|
|
ctx.Db.NullStringNullable.Iter().Any(r => r.Name == null),
|
|
"Expected a row inserted into null_string_nullable with Name == null"
|
|
);
|
|
};
|
|
|
|
conn.Reducers.OnUpdateWhereTest += (
|
|
ReducerEventContext ctx,
|
|
uint id,
|
|
uint value,
|
|
string name
|
|
) =>
|
|
{
|
|
Log.Info("Got UpdateWhereTest callback");
|
|
waiting--;
|
|
Debug.Assert(id == 2, $"Expected UpdateWhereTest to target id=2, got {id}");
|
|
Debug.Assert(
|
|
value == UPDATED_WHERE_TEST_VALUE,
|
|
$"Expected UpdateWhereTest value {UPDATED_WHERE_TEST_VALUE}, got {value}"
|
|
);
|
|
Debug.Assert(
|
|
name == UPDATED_WHERE_TEST_NAME,
|
|
$"Expected UpdateWhereTest name {UPDATED_WHERE_TEST_NAME}, got {name}"
|
|
);
|
|
|
|
ValidateWhereSubscription(ctx, UPDATED_WHERE_TEST_NAME);
|
|
ValidateWhereTestViews(ctx, UPDATED_WHERE_TEST_VALUE, UPDATED_WHERE_TEST_NAME);
|
|
ValidateIEnumerableViews(ctx);
|
|
};
|
|
|
|
conn.Db.TestEvent.OnInsert += (EventContext ctx, TestEvent row) =>
|
|
{
|
|
Log.Info($"Got TestEvent.OnInsert callback: {row.Name} / {row.Value}");
|
|
testEventInsertCount++;
|
|
Debug.Assert(
|
|
row.Name == EXPECTED_TEST_EVENT_NAME,
|
|
$"Expected TestEvent.Name == {EXPECTED_TEST_EVENT_NAME}, got {row.Name}"
|
|
);
|
|
Debug.Assert(
|
|
row.Value == EXPECTED_TEST_EVENT_VALUE,
|
|
$"Expected TestEvent.Value == {EXPECTED_TEST_EVENT_VALUE}, got {row.Value}"
|
|
);
|
|
Debug.Assert(
|
|
ctx.Db.TestEvent.Count == 0,
|
|
$"Event table should not persist rows. Count was {ctx.Db.TestEvent.Count}"
|
|
);
|
|
Debug.Assert(
|
|
!ctx.Db.TestEvent.Iter().Any(),
|
|
"Event table iterator should be empty after event delivery"
|
|
);
|
|
};
|
|
|
|
conn.Reducers.OnEmitTestEvent += (ReducerEventContext ctx, string name, ulong value) =>
|
|
{
|
|
Log.Info("Got EmitTestEvent callback");
|
|
waiting--;
|
|
Debug.Assert(
|
|
ctx.Event.Status is Status.Committed,
|
|
$"EmitTestEvent should commit, got {ctx.Event.Status}"
|
|
);
|
|
Debug.Assert(
|
|
name == EXPECTED_TEST_EVENT_NAME,
|
|
$"Expected name={EXPECTED_TEST_EVENT_NAME}, got {name}"
|
|
);
|
|
Debug.Assert(
|
|
value == EXPECTED_TEST_EVENT_VALUE,
|
|
$"Expected value={EXPECTED_TEST_EVENT_VALUE}, got {value}"
|
|
);
|
|
};
|
|
|
|
conn.Reducers.OnNoop += (ReducerEventContext ctx) =>
|
|
{
|
|
Log.Info("Got Noop callback");
|
|
waiting--;
|
|
Debug.Assert(
|
|
testEventInsertCount == 1,
|
|
$"Expected exactly one TestEvent insert callback after noop, got {testEventInsertCount}"
|
|
);
|
|
Debug.Assert(
|
|
ctx.Db.TestEvent.Count == 0,
|
|
$"Event table should still be empty after noop. Count was {ctx.Db.TestEvent.Count}"
|
|
);
|
|
Debug.Assert(
|
|
!ctx.Db.TestEvent.Iter().Any(),
|
|
"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);
|
|
};
|
|
|
|
// Equality test reducer callbacks
|
|
conn.Reducers.OnRunAllEqualityTests += (ReducerEventContext ctx) =>
|
|
{
|
|
Log.Info("Got RunAllEqualityTests callback");
|
|
// Note: waiting-- happens after validation in this callback
|
|
Debug.Assert(
|
|
ctx.Event.Status is Status.Committed,
|
|
$"RunAllEqualityTests should commit, got {ctx.Event.Status}"
|
|
);
|
|
|
|
// Validate results while subscription is still active
|
|
// Must happen here before UnsubscribeThen removes the data
|
|
if (ctx.Event.Status is Status.Committed)
|
|
{
|
|
Log.Info("Validating equality test results...");
|
|
ValidateEqualityResults(ctx);
|
|
Log.Info("Equality tests completed and validated");
|
|
}
|
|
// Decrement waiting here after validation is complete
|
|
waiting--;
|
|
};
|
|
|
|
conn.Reducers.OnAddEqualityPerson += (ReducerEventContext ctx, uint id, string name) =>
|
|
{
|
|
Log.Info($"Got AddEqualityPerson callback: id={id}, name={name}");
|
|
// Note: waiting-- happens in OnRunAllEqualityTests after all tests complete
|
|
};
|
|
|
|
conn.Reducers.OnAddEqualityProduct += (ReducerEventContext ctx, uint id, string name, int price, int quantity) =>
|
|
{
|
|
Log.Info($"Got AddEqualityProduct callback: id={id}, name={name}");
|
|
// Note: waiting-- happens in OnRunAllEqualityTests after all tests complete
|
|
};
|
|
|
|
conn.Reducers.OnRunEqualityTests += (ReducerEventContext ctx) =>
|
|
{
|
|
Log.Info("Got RunEqualityTests callback");
|
|
// Note: waiting-- happens in OnRunAllEqualityTests after all tests complete
|
|
};
|
|
|
|
conn.Reducers.OnRunComplexEqualityTests += (ReducerEventContext ctx) =>
|
|
{
|
|
Log.Info("Got RunComplexEqualityTests callback");
|
|
// Note: waiting-- happens in OnRunAllEqualityTests after all tests complete
|
|
};
|
|
|
|
conn.Reducers.OnRunEnumEqualityTests += (ReducerEventContext ctx) =>
|
|
{
|
|
Log.Info("Got RunEnumEqualityTests callback");
|
|
// Note: waiting-- happens in OnRunAllEqualityTests after all tests complete
|
|
};
|
|
|
|
// Equality test table insert callbacks
|
|
conn.Db.EqualityPerson.OnInsert += (EventContext ctx, EqualityPerson row) =>
|
|
{
|
|
Log.Info($"EqualityPerson.OnInsert: Id={row.Id}, Name={row.Name}");
|
|
};
|
|
|
|
conn.Db.EqualityProduct.OnInsert += (EventContext ctx, EqualityProduct row) =>
|
|
{
|
|
Log.Info($"EqualityProduct.OnInsert: Id={row.Id}, Name={row.Name}, Price={row.Price}");
|
|
};
|
|
|
|
conn.Db.EqualityOrder.OnInsert += (EventContext ctx, EqualityOrder row) =>
|
|
{
|
|
Log.Info($"EqualityOrder.OnInsert: Id={row.Id}, Customer={row.CustomerName}");
|
|
};
|
|
|
|
conn.Db.PlayerAction.OnInsert += (EventContext ctx, PlayerAction row) =>
|
|
{
|
|
var actionDesc = row.Action switch
|
|
{
|
|
GameAction.Move(var m) => $"Move({m})",
|
|
GameAction.Attack(var a) => $"Attack({a})",
|
|
GameAction.Defend(var d) => $"Defend({d})",
|
|
_ => "Unknown"
|
|
};
|
|
Log.Info($"PlayerAction.OnInsert: Id={row.Id}, Action={actionDesc}");
|
|
};
|
|
|
|
conn.Db.ActionBatch.OnInsert += (EventContext ctx, ActionBatch row) =>
|
|
{
|
|
Log.Info($"ActionBatch.OnInsert: Id={row.Id}, Actions.Count={row.Actions?.Count ?? 0}");
|
|
};
|
|
|
|
conn.Db.LogEntry.OnInsert += (EventContext ctx, LogEntry row) =>
|
|
{
|
|
Log.Info($"LogEntry.OnInsert: {row.Message} at {row.Timestamp}");
|
|
};
|
|
}
|
|
|
|
const uint MAX_ID = 10;
|
|
|
|
// Test that indexes remain in sync with the expected table state when deletes are received.
|
|
// This used to fail, when row types did not correctly implement IEquatable.
|
|
void ValidateBTreeIndexes(IRemoteDbContext conn)
|
|
{
|
|
Log.Debug("Checking indexes...");
|
|
foreach (var data in conn.Db.ExampleData.Iter())
|
|
{
|
|
Debug.Assert(conn.Db.ExampleData.Indexed.Filter(data.Id).Contains(data));
|
|
}
|
|
var outOfIndex = conn.Db.ExampleData.Iter().ToHashSet();
|
|
|
|
for (uint i = 0; i < MAX_ID; i++)
|
|
{
|
|
foreach (var data in conn.Db.ExampleData.Indexed.Filter(i))
|
|
{
|
|
Debug.Assert(outOfIndex.Contains(data));
|
|
}
|
|
}
|
|
Log.Debug(" Indexes are good.");
|
|
}
|
|
|
|
void ValidateNullableVecView(
|
|
IRemoteDbContext conn,
|
|
bool? expectedHasPos = null,
|
|
int expectedX = 0,
|
|
int expectedY = 0
|
|
)
|
|
{
|
|
Log.Debug("Checking nullable vec view...");
|
|
Debug.Assert(conn.Db.NullableVecView != null, "conn.Db.NullableVecView != null");
|
|
Debug.Assert(
|
|
conn.Db.NullableVecView.Count >= 2,
|
|
$"conn.Db.NullableVecView.Count = {conn.Db.NullableVecView.Count}"
|
|
);
|
|
|
|
var rows = conn.Db.NullableVecView.Iter().ToList();
|
|
Debug.Assert(rows.Any(r => r.Id == 1));
|
|
Debug.Assert(rows.Any(r => r.Id == 2));
|
|
|
|
var remoteRows = conn.Db.NullableVecView.RemoteQuery("WHERE Id = 1").Result;
|
|
Debug.Assert(remoteRows != null && remoteRows.Length == 1);
|
|
Debug.Assert(remoteRows[0].Id == 1);
|
|
|
|
if (expectedHasPos is bool hasPos)
|
|
{
|
|
var row1 = rows.First(r => r.Id == 1);
|
|
if (!hasPos)
|
|
{
|
|
Debug.Assert(row1.Pos == null, "Expected NullableVecView row 1 Pos == null");
|
|
}
|
|
else
|
|
{
|
|
Debug.Assert(row1.Pos != null, "Expected NullableVecView row 1 Pos != null");
|
|
Debug.Assert(
|
|
row1.Pos.X == expectedX,
|
|
$"Expected row1.Pos.X == {expectedX}, got {row1.Pos.X}"
|
|
);
|
|
Debug.Assert(
|
|
row1.Pos.Y == expectedY,
|
|
$"Expected row1.Pos.Y == {expectedY}, got {row1.Pos.Y}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void ValidateReducerErrorDoesNotContainStackTrace(Exception exception)
|
|
{
|
|
Debug.Assert(
|
|
exception.Message == THROW_ERROR_MESSAGE,
|
|
$"Expected reducer error message '{THROW_ERROR_MESSAGE}', got '{exception.Message}'"
|
|
);
|
|
Debug.Assert(
|
|
!exception.Message.Contains("\n"),
|
|
"Reducer error message should not contain newline"
|
|
);
|
|
Debug.Assert(
|
|
!exception.Message.Contains("\r"),
|
|
"Reducer error message should not contain newline"
|
|
);
|
|
Debug.Assert(
|
|
!exception.Message.Contains(" at "),
|
|
"Reducer error message should not contain stack trace"
|
|
);
|
|
}
|
|
|
|
void ValidateQueryingWithIndexesExamples(IRemoteDbContext conn)
|
|
{
|
|
Log.Debug("Checking 'Querying with Indexes' documentation examples...");
|
|
|
|
var usersNamedAlice = conn.Db.UsersNamedAlice.Iter().Select(u => u.Name).ToList();
|
|
Debug.Assert(
|
|
usersNamedAlice.Count == 1 && usersNamedAlice[0] == "Alice",
|
|
"Expected exactly one Alice in users_named_alice view"
|
|
);
|
|
|
|
var ages18To65 = conn.Db.UsersAge1865.Iter().Select(u => u.Name).ToHashSet();
|
|
Debug.Assert(
|
|
ages18To65.SetEquals(new[] { "Alice", "Charlie" }),
|
|
"Expected Alice and Charlie in 18-65 age range"
|
|
);
|
|
|
|
var ages18OrOlder = conn.Db.UsersAge18Plus.Iter().Select(u => u.Name).ToHashSet();
|
|
Debug.Assert(
|
|
ages18OrOlder.SetEquals(new[] { "Alice", "Charlie" }),
|
|
"Expected Alice and Charlie to be >= 18"
|
|
);
|
|
|
|
var youngerThan18 = conn.Db.UsersAgeUnder18.Iter().Select(u => u.Name).ToHashSet();
|
|
Debug.Assert(youngerThan18.SetEquals(new[] { "Bob" }), "Expected Bob to be the only minor");
|
|
|
|
var player123Scores = conn.Db.ScoresPlayer123.Iter().ToList();
|
|
Debug.Assert(
|
|
player123Scores.Count == 3,
|
|
$"Expected 3 scores for player 123, got {player123Scores.Count}"
|
|
);
|
|
|
|
var player123LevelRange = conn.Db.ScoresPlayer123Range.Iter().ToList();
|
|
Debug.Assert(
|
|
player123LevelRange.Count == 3,
|
|
"Expected three scores for player 123 between levels 1 and 10 inclusive"
|
|
);
|
|
|
|
var player123Level5 = conn.Db.ScoresPlayer123Level5.Iter().ToList();
|
|
Debug.Assert(
|
|
player123Level5.Count == 1 && player123Level5[0].Points == 5_000,
|
|
"Expected a single level-5 score worth 5,000 points for player 123"
|
|
);
|
|
|
|
Log.Debug("Checking advanced typed query builder predicates...");
|
|
Debug.Assert(conn.Db.User != null, "conn.Db.User should not be null");
|
|
var advancedUsers = conn.Db.User.Iter().ToList();
|
|
Debug.Assert(
|
|
advancedUsers.Count == 2,
|
|
$"Expected 2 rows from advanced user predicate, got {advancedUsers.Count}"
|
|
);
|
|
Debug.Assert(
|
|
advancedUsers.All(u => u.Age >= 18 && u.Age < 65),
|
|
"Advanced predicate rows should have 18 <= age < 65"
|
|
);
|
|
Debug.Assert(
|
|
advancedUsers.All(u => u.IsAdmin || u.Name == "Charlie"),
|
|
"Advanced predicate rows should satisfy admin || name == Charlie"
|
|
);
|
|
Debug.Assert(
|
|
advancedUsers
|
|
.Select(u => u.Name)
|
|
.OrderBy(n => n)
|
|
.SequenceEqual(new[] { "Alice", "Charlie" }),
|
|
"Expected Alice and Charlie from advanced predicate"
|
|
);
|
|
}
|
|
|
|
void ValidateWhereSubscription(
|
|
IRemoteDbContext conn,
|
|
string expectedTestName = "this_name_will_get_updated"
|
|
)
|
|
{
|
|
Log.Debug("Checking typed WHERE subscription...");
|
|
Debug.Assert(conn.Db.WhereTest != null, "conn.Db.WhereTest != null");
|
|
|
|
var rows = conn.Db.WhereTest.Iter().ToList();
|
|
Debug.Assert(rows.Count == 2, $"Expected 2 where_test rows, got {rows.Count}");
|
|
Debug.Assert(rows.All(r => r.Value > 10), "Expected all where_test.Value > 10");
|
|
Debug.Assert(
|
|
rows.Any(r => r.Id == 2 && r.Name == expectedTestName),
|
|
$"Expected where_test row id=2 name={expectedTestName}"
|
|
);
|
|
Debug.Assert(
|
|
rows.Any(r => r.Id == 3 && r.Name == "this_name_will_not_be_updated"),
|
|
"Expected where_test row id=3 name=this_name_will_not_be_updated"
|
|
);
|
|
}
|
|
|
|
void ValidateWhereTestViews(
|
|
IRemoteDbContext conn,
|
|
uint expectedId2Value = 15,
|
|
string expectedId2Name = "this_name_will_get_updated"
|
|
)
|
|
{
|
|
Log.Debug("Checking where_test views...");
|
|
Debug.Assert(conn.Db.WhereTestView != null, "WhereTestView should not be null");
|
|
Debug.Assert(
|
|
conn.Db.WhereTestView.Count == 1,
|
|
$"Expected exactly one WhereTestView row, got {conn.Db.WhereTestView.Count}"
|
|
);
|
|
var viewRow = conn.Db.WhereTestView.Iter().First();
|
|
Debug.Assert(viewRow.Id == 2, $"Expected WhereTestView row id=2, got {viewRow.Id}");
|
|
Debug.Assert(
|
|
viewRow.Value == expectedId2Value,
|
|
$"Expected WhereTestView row value={expectedId2Value}, got {viewRow.Value}"
|
|
);
|
|
Debug.Assert(
|
|
viewRow.Name == expectedId2Name,
|
|
$"Expected WhereTestView row name={expectedId2Name}, got {viewRow.Name}"
|
|
);
|
|
|
|
Debug.Assert(conn.Db.WhereTestQuery != null, "WhereTestQuery should not be null");
|
|
Debug.Assert(
|
|
conn.Db.WhereTestQuery.Count == 1,
|
|
$"Expected exactly one WhereTestQuery row, got {conn.Db.WhereTestQuery.Count}"
|
|
);
|
|
var queryRow = conn.Db.WhereTestQuery.Iter().First();
|
|
Debug.Assert(queryRow.Id == 2, $"Expected WhereTestQuery row id=2, got {queryRow.Id}");
|
|
Debug.Assert(
|
|
queryRow.Value == expectedId2Value,
|
|
$"Expected WhereTestQuery row value={expectedId2Value}, got {queryRow.Value}"
|
|
);
|
|
Debug.Assert(
|
|
queryRow.Name == expectedId2Name,
|
|
$"Expected WhereTestQuery row name={expectedId2Name}, got {queryRow.Name}"
|
|
);
|
|
|
|
Debug.Assert(conn.Db.FindWhereTest != null, "FindWhereTest should not be null");
|
|
Debug.Assert(
|
|
conn.Db.FindWhereTest.Count == 1,
|
|
$"Expected exactly one FindWhereTest row, got {conn.Db.FindWhereTest.Count}"
|
|
);
|
|
var anonRow = conn.Db.FindWhereTest.Iter().First();
|
|
Debug.Assert(anonRow.Id == 3, $"Expected FindWhereTest row id=3, got {anonRow.Id}");
|
|
}
|
|
|
|
void ValidateIEnumerableViews(IRemoteDbContext conn)
|
|
{
|
|
Log.Debug("Checking IEnumerable views...");
|
|
|
|
// Validate IEnumerablePlayersFromIter - should return all players
|
|
Debug.Assert(
|
|
conn.Db.IenumerablePlayersFromIter != null,
|
|
"IenumerablePlayersFromIter should not be null"
|
|
);
|
|
var players = conn.Db.IenumerablePlayersFromIter.Iter().ToList();
|
|
Debug.Assert(players.Count >= 1, $"Expected at least 1 player, got {players.Count}");
|
|
|
|
// Validate IEnumerableAdminsFromFilter - should return only admin users
|
|
Debug.Assert(
|
|
conn.Db.IenumerableAdminsFromFilter != null,
|
|
"IenumerableAdminsFromFilter should not be null"
|
|
);
|
|
var admins = conn.Db.IenumerableAdminsFromFilter.Iter().ToList();
|
|
Debug.Assert(admins.Count >= 1, $"Expected at least 1 admin user, got {admins.Count}");
|
|
Debug.Assert(
|
|
admins.All(u => u.IsAdmin),
|
|
"All users in IenumerableAdminsFromFilter should be admins"
|
|
);
|
|
|
|
// Validate IEnumerablePlayersWithLevels - should return players with their levels
|
|
Debug.Assert(
|
|
conn.Db.IenumerablePlayersWithLevels != null,
|
|
"IenumerablePlayersWithLevels should not be null"
|
|
);
|
|
var playersWithLevels = conn.Db.IenumerablePlayersWithLevels.Iter().ToList();
|
|
Debug.Assert(
|
|
playersWithLevels.Count >= 1,
|
|
$"Expected at least 1 player with level, got {playersWithLevels.Count}"
|
|
);
|
|
|
|
// Verify the structure matches PlayerAndLevel
|
|
foreach (var playerWithLevel in playersWithLevels)
|
|
{
|
|
Debug.Assert(playerWithLevel.Id > 0, "PlayerAndLevel.Id should be > 0");
|
|
Debug.Assert(
|
|
!string.IsNullOrEmpty(playerWithLevel.Name),
|
|
"PlayerAndLevel.Name should not be null or empty"
|
|
);
|
|
Debug.Assert(playerWithLevel.Level > 0, "PlayerAndLevel.Level should be > 0");
|
|
}
|
|
|
|
Log.Debug("IEnumerable views validation completed successfully");
|
|
}
|
|
|
|
void ValidateEqualitySubscriptions(IRemoteDbContext conn)
|
|
{
|
|
Log.Debug("Checking equality test subscriptions...");
|
|
|
|
// Verify all equality test tables are accessible
|
|
Debug.Assert(conn.Db.EqualityPerson != null, "EqualityPerson subscription should not be null");
|
|
Debug.Assert(conn.Db.EqualityProduct != null, "EqualityProduct subscription should not be null");
|
|
Debug.Assert(conn.Db.EqualityOrder != null, "EqualityOrder subscription should not be null");
|
|
Debug.Assert(conn.Db.PlayerAction != null, "PlayerAction subscription should not be null");
|
|
Debug.Assert(conn.Db.ActionBatch != null, "ActionBatch subscription should not be null");
|
|
Debug.Assert(conn.Db.LogEntry != null, "LogEntry subscription should not be null");
|
|
|
|
Log.Debug("Equality test subscriptions validated successfully");
|
|
}
|
|
|
|
void RunEqualityTests(SubscriptionEventContext ctx)
|
|
{
|
|
Log.Debug("Running equality tests...");
|
|
|
|
// Single waiting increment for the entire equality test phase
|
|
// This ensures we wait for all callbacks AND validation before proceeding
|
|
waiting++;
|
|
|
|
// Test 1: Add a person
|
|
Log.Debug("Equality Test: Adding EqualityPerson...");
|
|
ctx.Reducers.AddEqualityPerson(1, "Alice");
|
|
|
|
// Test 2: Add a product
|
|
Log.Debug("Equality Test: Adding EqualityProduct...");
|
|
ctx.Reducers.AddEqualityProduct(1, "Widget", 999, 100);
|
|
|
|
// Test 3: Run the comprehensive equality test suite
|
|
Log.Debug("Equality Test: Running RunAllEqualityTests...");
|
|
ctx.Reducers.RunAllEqualityTests();
|
|
|
|
Log.Debug("Equality tests initiated - waiting for completion");
|
|
}
|
|
|
|
void ValidateEqualityResults(IRemoteDbContext conn)
|
|
{
|
|
Log.Debug("Validating equality test results...");
|
|
|
|
// Validate EqualityPerson data
|
|
var alice = conn.Db.EqualityPerson.Id.Find(1);
|
|
Debug.Assert(alice != null, "Alice should be in EqualityPerson table");
|
|
Debug.Assert(alice.Name == "Alice", $"Expected Alice, got {alice.Name}");
|
|
|
|
// Validate EqualityProduct data
|
|
var product = conn.Db.EqualityProduct.Id.Find(1);
|
|
Debug.Assert(product != null, "Widget should be in EqualityProduct table");
|
|
Debug.Assert(product.Name == "Widget", $"Expected Widget, got {product.Name}");
|
|
Debug.Assert(product.Price == 999, $"Expected Price=999, got {product.Price}");
|
|
|
|
// Validate PlayerAction data (inserted by TestSumTypeEquality)
|
|
var actions = conn.Db.PlayerAction.Iter().ToList();
|
|
Debug.Assert(actions.Count >= 2, $"Expected at least 2 PlayerActions, got {actions.Count}");
|
|
|
|
// Validate ActionBatch data (inserted by TestListOfNullableSumTypes)
|
|
var batches = conn.Db.ActionBatch.Iter().ToList();
|
|
Debug.Assert(batches.Count >= 1, $"Expected at least 1 ActionBatch, got {batches.Count}");
|
|
var batch = batches.FirstOrDefault(b => b.Id == 1);
|
|
Debug.Assert(batch != null, "Expected ActionBatch with Id=1");
|
|
Debug.Assert(batch.Actions != null, "ActionBatch should have non-null Actions list");
|
|
Debug.Assert(batch.Actions.Count == 4, $"Expected 4 actions (2 null), got {batch.Actions.Count}");
|
|
|
|
// Validate LogEntry data (inserted by TestTableWithoutPrimaryKey)
|
|
var logEntries = conn.Db.LogEntry.Iter().ToList();
|
|
Debug.Assert(logEntries.Count >= 2, $"Expected at least 2 LogEntries, got {logEntries.Count}");
|
|
|
|
Log.Debug("Equality test results validated successfully");
|
|
}
|
|
|
|
void ValidateSemijoinSubscriptions(IRemoteDbContext conn, Identity identity)
|
|
{
|
|
Log.Debug("Checking typed semijoin subscriptions...");
|
|
|
|
var players = conn.Db.Player.Iter().ToList();
|
|
Debug.Assert(players.Count == 1, $"Expected 1 player row, got {players.Count}");
|
|
Debug.Assert(
|
|
players[0].Identity == identity,
|
|
"Expected player.Identity to match the connection identity"
|
|
);
|
|
Debug.Assert(
|
|
players[0].Name == "NewPlayer",
|
|
$"Expected player.Name == NewPlayer, got {players[0].Name}"
|
|
);
|
|
|
|
var playerId = players[0].Id;
|
|
|
|
var levels = conn.Db.PlayerLevel.Iter().ToList();
|
|
Debug.Assert(levels.Count == 1, $"Expected 1 player_level row, got {levels.Count}");
|
|
Debug.Assert(
|
|
levels[0].PlayerId == playerId,
|
|
"Expected player_level.PlayerId to match the subscribed player id"
|
|
);
|
|
Debug.Assert(levels[0].Level == 1, $"Expected player_level.Level == 1, got {levels[0].Level}");
|
|
}
|
|
|
|
void 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;
|
|
}
|
|
|
|
/// 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();
|
|
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}");
|
|
ExecViewPkJoinQueryBuilder();
|
|
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();
|
|
}
|
|
|
|
/// 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();
|
|
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}");
|
|
ExecViewPkSemijoinTwoSenderViewsQueryBuilder();
|
|
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();
|
|
}
|
|
|
|
/// 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();
|
|
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}");
|
|
ExecProceduralViewPkOnUpdate();
|
|
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();
|
|
}
|
|
|
|
/// Subscribe to a procedural view with an explicitly declared 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 procedural_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 ExecProceduralViewPkOnUpdate()
|
|
{
|
|
const string testName = "procedural-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 OnProceduralViewPkPlayersUpdate(
|
|
EventContext _,
|
|
ViewPkPlayer oldRow,
|
|
ViewPkPlayer newRow
|
|
)
|
|
{
|
|
ctx.Db.ProceduralViewPkPlayers.OnUpdate -= OnProceduralViewPkPlayersUpdate;
|
|
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.ProceduralViewPkPlayers.OnUpdate += OnProceduralViewPkPlayersUpdate;
|
|
|
|
waiting++;
|
|
ctx.Reducers.InsertViewPkPlayer(playerId, before);
|
|
|
|
waiting++;
|
|
ctx.Reducers.UpdateViewPkPlayer(playerId, after);
|
|
})
|
|
.OnError(
|
|
(_, err) =>
|
|
{
|
|
throw err;
|
|
}
|
|
)
|
|
.AddQuery(q => q.From.ProceduralViewPkPlayers())
|
|
.Subscribe();
|
|
}
|
|
|
|
void OnSubscriptionApplied(SubscriptionEventContext context)
|
|
{
|
|
ValidateWhereSubscription(context);
|
|
ValidateWhereTestViews(context);
|
|
ValidateIEnumerableViews(context);
|
|
ValidateSemijoinSubscriptions(context, context.Identity!.Value);
|
|
|
|
// Run equality tests
|
|
ValidateEqualitySubscriptions(context);
|
|
RunEqualityTests(context);
|
|
|
|
// Do some operations that alter row state;
|
|
// we will check that everything is in sync in the callbacks for these reducer calls.
|
|
Log.Debug("Calling Add");
|
|
waiting++;
|
|
context.Reducers.Add(1, 1);
|
|
|
|
Log.Debug("Calling Delete");
|
|
waiting++;
|
|
context.Reducers.Delete(1);
|
|
|
|
Log.Debug("Calling Add");
|
|
waiting++;
|
|
context.Reducers.Add(1, 1);
|
|
|
|
Log.Debug("Updating where_test row via reducer");
|
|
waiting++;
|
|
context.Reducers.UpdateWhereTest(2, UPDATED_WHERE_TEST_VALUE, UPDATED_WHERE_TEST_NAME);
|
|
|
|
Log.Debug("Calling ThrowError");
|
|
waiting++;
|
|
context.Reducers.ThrowError(THROW_ERROR_MESSAGE);
|
|
|
|
Log.Debug("Calling InsertResult");
|
|
waiting++;
|
|
context.Reducers.InsertResult(
|
|
Result<MyTable, string>.Ok(new MyTable(new ReturnStruct(42, "magic")))
|
|
);
|
|
waiting++;
|
|
context.Reducers.InsertResult(Result<MyTable, string>.Err("Fail"));
|
|
|
|
Log.Debug("Calling RemoteQuery on my_log");
|
|
var logRows = context.Db.MyLog.RemoteQuery("").Result;
|
|
Debug.Assert(logRows != null && logRows.Length == 2);
|
|
var logs = logRows.ToArray();
|
|
var expected = new[]
|
|
{
|
|
new MyLog(Result<MyTable, string>.Ok(new MyTable(new ReturnStruct(42, "magic")))),
|
|
new MyLog(Result<MyTable, string>.Err("Fail")),
|
|
};
|
|
Debug.Assert(logs.SequenceEqual(expected), "Logs did not match expected results");
|
|
|
|
// RemoteQuery test
|
|
Log.Debug("Calling RemoteQuery");
|
|
// If testing against Rust, the query will need to be changed to "WHERE id = 0"
|
|
var remoteRows = context.Db.ExampleData.RemoteQuery("WHERE Id = 1").Result;
|
|
Debug.Assert(remoteRows != null && remoteRows.Length > 0);
|
|
|
|
Log.Debug("Calling Admins.RemoteQuery");
|
|
var remoteAdminRows = context.Db.Admins.RemoteQuery("WHERE IsAdmin = true").Result;
|
|
Debug.Assert(remoteAdminRows != null && remoteAdminRows.Length > 0);
|
|
|
|
// Views test
|
|
Log.Debug("Checking Views are populated");
|
|
Debug.Assert(context.Db.MyPlayer != null, "context.Db.MyPlayer != null");
|
|
Debug.Assert(context.Db.MyAccount != null, "context.Db.MyAccount != null");
|
|
Debug.Assert(context.Db.MyAccountMissing != null, "context.Db.MyAccountMissing != null");
|
|
Debug.Assert(context.Db.PlayersAtLevelOne != null, "context.Db.PlayersAtLevelOne != null");
|
|
Debug.Assert(
|
|
context.Db.MyPlayer.Count > 0,
|
|
$"context.Db.MyPlayer.Count = {context.Db.MyPlayer.Count}"
|
|
);
|
|
Debug.Assert(
|
|
context.Db.MyAccount.Count == 1,
|
|
$"context.Db.MyAccount.Count = {context.Db.MyAccount.Count}"
|
|
);
|
|
Debug.Assert(
|
|
context.Db.MyAccountMissing.Count == 0,
|
|
$"context.Db.MyAccountMissing.Count = {context.Db.MyAccountMissing.Count}"
|
|
);
|
|
Debug.Assert(
|
|
context.Db.PlayersAtLevelOne.Count > 0,
|
|
$"context.Db.PlayersAtLevelOne.Count = {context.Db.PlayersAtLevelOne.Count}"
|
|
);
|
|
Debug.Assert(context.Db.Admins != null, "context.Db.Admins != null");
|
|
Debug.Assert(
|
|
context.Db.Admins.Count > 0,
|
|
$"context.Db.Admins.Count = {context.Db.Admins.Count}"
|
|
);
|
|
|
|
ValidateNullableVecView(context, expectedHasPos: true, expectedX: 1, expectedY: 2);
|
|
|
|
ValidateQueryingWithIndexesExamples(context);
|
|
|
|
Log.Debug("Calling Iter on View");
|
|
var viewIterRows = context.Db.MyPlayer.Iter();
|
|
var expectedPlayer = new Player
|
|
{
|
|
Id = 1,
|
|
Identity = context.Identity!.Value,
|
|
Name = "NewPlayer",
|
|
};
|
|
Log.Debug(
|
|
"MyPlayer Iter count: " + (viewIterRows != null ? viewIterRows.Count().ToString() : "null")
|
|
);
|
|
Debug.Assert(viewIterRows != null && viewIterRows.Any());
|
|
Log.Debug(
|
|
"Validating View row data "
|
|
+ $"Id={expectedPlayer.Id}, Identity={expectedPlayer.Identity}, Name={expectedPlayer.Name} => "
|
|
+ $"Id={viewIterRows.First().Id}, Identity={viewIterRows.First().Identity}, Name={viewIterRows.First().Name}"
|
|
);
|
|
Debug.Assert(viewIterRows.First().Equals(expectedPlayer));
|
|
|
|
Log.Debug("Calling Iter on View Admins");
|
|
var adminsIterRows = context.Db.Admins.Iter();
|
|
var expectedAdminNames = new HashSet<string> { "Alice", "Charlie" };
|
|
Log.Debug(
|
|
"Admins Iter count: "
|
|
+ (adminsIterRows != null ? adminsIterRows.Count().ToString() : "null")
|
|
);
|
|
Debug.Assert(adminsIterRows != null && adminsIterRows.Any());
|
|
Log.Debug(
|
|
"Validating Admins View row data "
|
|
+ $"Expected Names={string.Join(", ", expectedAdminNames)} => "
|
|
+ $"Actual Names={string.Join(", ", adminsIterRows.Select(a => a.Name))}"
|
|
);
|
|
Debug.Assert(adminsIterRows.All(a => expectedAdminNames.Contains(a.Name)));
|
|
|
|
Log.Debug("Calling RemoteQuery on View");
|
|
// If testing against Rust, the query will need to be changed to "WHERE id > 0"
|
|
var viewRemoteQueryRows = context.Db.MyPlayer.RemoteQuery("WHERE Id > 0");
|
|
Debug.Assert(viewRemoteQueryRows != null && viewRemoteQueryRows.Result.Length > 0);
|
|
Debug.Assert(viewRemoteQueryRows.Result.First().Equals(expectedPlayer));
|
|
|
|
Log.Debug("Calling Iter on Anonymous View");
|
|
var anonViewIterRows = context.Db.PlayersAtLevelOne.Iter();
|
|
var expectedPlayerAndLevel = new PlayerAndLevel
|
|
{
|
|
Id = 1,
|
|
Identity = context.Identity!.Value,
|
|
Name = "NewPlayer",
|
|
Level = 1,
|
|
};
|
|
Log.Debug(
|
|
"PlayersAtLevelOne Iter count: "
|
|
+ (anonViewIterRows != null ? anonViewIterRows.Count().ToString() : "null")
|
|
);
|
|
Debug.Assert(anonViewIterRows != null && anonViewIterRows.Any());
|
|
Log.Debug(
|
|
"Validating Anonymous View row data "
|
|
+ $"Id={expectedPlayerAndLevel.Id}, Identity={expectedPlayerAndLevel.Identity}, Name={expectedPlayerAndLevel.Name}, Level={expectedPlayerAndLevel.Level} => "
|
|
+ $"Id={anonViewIterRows.First().Id}, Identity={anonViewIterRows.First().Identity}, Name={anonViewIterRows.First().Name}, Level={anonViewIterRows.First().Level} => "
|
|
);
|
|
Debug.Assert(anonViewIterRows.First().Equals(expectedPlayerAndLevel));
|
|
|
|
Log.Debug("Calling RemoteQuery on Anonymous View");
|
|
// If testing against Rust, the query will need to be changed to "WHERE level = 1"
|
|
var anonViewRemoteQueryRows = context.Db.PlayersAtLevelOne.RemoteQuery("WHERE Level = 1");
|
|
Log.Debug(
|
|
"PlayersAtLevelOne RemoteQuery count: "
|
|
+ (
|
|
anonViewRemoteQueryRows != null
|
|
? anonViewRemoteQueryRows.Result.Length.ToString()
|
|
: "null"
|
|
)
|
|
);
|
|
Debug.Assert(anonViewRemoteQueryRows != null && anonViewRemoteQueryRows.Result.Length > 0);
|
|
Debug.Assert(anonViewRemoteQueryRows.Result.First().Equals(expectedPlayerAndLevel));
|
|
|
|
Log.Debug("Calling SetNullableVec (null)");
|
|
waiting++;
|
|
context.Reducers.SetNullableVec(1, false, 0, 0);
|
|
|
|
Log.Debug("Calling SetNullableVec (some)");
|
|
waiting++;
|
|
context.Reducers.SetNullableVec(1, true, 7, 8);
|
|
|
|
Log.Debug("Calling InsertEmptyStringIntoNonNullable");
|
|
waiting++;
|
|
context.Reducers.InsertEmptyStringIntoNonNullable();
|
|
|
|
Log.Debug("Calling InsertNullStringIntoNonNullable (should fail)");
|
|
waiting++;
|
|
context.Reducers.InsertNullStringIntoNonNullable();
|
|
|
|
Log.Debug("Calling InsertNullStringIntoNullable");
|
|
waiting++;
|
|
context.Reducers.InsertNullStringIntoNullable();
|
|
|
|
Log.Debug("Calling EmitTestEvent");
|
|
waiting++;
|
|
context.Reducers.EmitTestEvent(EXPECTED_TEST_EVENT_NAME, EXPECTED_TEST_EVENT_VALUE);
|
|
|
|
Log.Debug("Calling Noop after EmitTestEvent");
|
|
waiting++;
|
|
context.Reducers.Noop();
|
|
|
|
// Procedures tests
|
|
Log.Debug("Calling ReadMySchemaViaHttp");
|
|
waiting++;
|
|
context.Procedures.ReadMySchemaViaHttp(
|
|
(IProcedureEventContext ctx, ProcedureCallbackResult<string> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(
|
|
result.IsSuccess,
|
|
$"ReadMySchemaViaHttp should succeed. Error received: {result.Error}"
|
|
);
|
|
Debug.Assert(result.Value != null, "ReadMySchemaViaHttp should return a string");
|
|
Debug.Assert(
|
|
result.Value.StartsWith("OK "),
|
|
$"Expected OK prefix, got: {result.Value}"
|
|
);
|
|
Debug.Assert(
|
|
result.Value.Contains("example_data"),
|
|
$"Expected schema response to mention example_data, got: {result.Value}"
|
|
);
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
}
|
|
);
|
|
|
|
Log.Debug("Calling InvalidHttpRequest");
|
|
waiting++;
|
|
context.Procedures.InvalidHttpRequest(
|
|
(IProcedureEventContext ctx, ProcedureCallbackResult<string> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(
|
|
result.IsSuccess,
|
|
$"InvalidHttpRequest should succeed. Error received: {result.Error}"
|
|
);
|
|
Debug.Assert(result.Value != null, "InvalidHttpRequest should return a string");
|
|
Debug.Assert(
|
|
result.Value.StartsWith("ERR "),
|
|
$"Expected ERR prefix, got: {result.Value}"
|
|
);
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
}
|
|
);
|
|
|
|
Log.Debug("Calling InsertWithTxRollback");
|
|
waiting++;
|
|
context.Procedures.InsertWithTxRollback(
|
|
(IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
|
|
{
|
|
if (result.IsSuccess)
|
|
{
|
|
Debug.Assert(
|
|
context.Db.MyTable.Count == 0,
|
|
$"MyTable should remain empty after rollback. Count was {context.Db.MyTable.Count}"
|
|
);
|
|
Log.Debug("Insert with transaction rollback succeeded");
|
|
}
|
|
else
|
|
{
|
|
throw new Exception(
|
|
"Expected InsertWithTransactionRollback to fail, but it succeeded"
|
|
);
|
|
}
|
|
waiting--;
|
|
}
|
|
);
|
|
|
|
Log.Debug("Calling InsertWithTxRollbackResult");
|
|
waiting++;
|
|
context.Procedures.InsertWithTxRollbackResult(
|
|
(
|
|
IProcedureEventContext ctx,
|
|
ProcedureCallbackResult<Result<ReturnStruct, string>> result
|
|
) =>
|
|
{
|
|
if (result.IsSuccess)
|
|
{
|
|
Debug.Assert(
|
|
context.Db.MyTable.Count == 0,
|
|
$"MyTable should remain empty after rollback result. Count was {context.Db.MyTable.Count}"
|
|
);
|
|
Log.Debug("Insert with transaction result rollback succeeded");
|
|
}
|
|
else
|
|
{
|
|
throw new Exception(
|
|
"Expected InsertWithTxRollbackResult to fail, but it succeeded"
|
|
);
|
|
}
|
|
waiting--;
|
|
}
|
|
);
|
|
|
|
Log.Debug("Calling InsertWithTxPanic");
|
|
waiting++;
|
|
context.Procedures.InsertWithTxPanic(
|
|
(IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(
|
|
result.IsSuccess,
|
|
$"InsertWithTxPanic should succeed (exception is caught). Error received: {result.Error}"
|
|
);
|
|
Debug.Assert(
|
|
context.Db.MyTable.Count == 0,
|
|
$"MyTable should remain empty after exception abort. Count was {context.Db.MyTable.Count}"
|
|
);
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
}
|
|
);
|
|
|
|
Log.Debug("Calling DanglingTxWarning");
|
|
waiting++;
|
|
context.Procedures.DanglingTxWarning(
|
|
(IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(
|
|
result.IsSuccess,
|
|
$"DanglingTxWarning should succeed. Error received: {result.Error}"
|
|
);
|
|
Debug.Assert(
|
|
context.Db.MyTable.Count == 0,
|
|
$"MyTable should remain empty after dangling tx auto-abort. Count was {context.Db.MyTable.Count}"
|
|
);
|
|
// Note: We can't easily assert on the warning log from client-side,
|
|
// but the server-side AssertRowCount verifies the auto-abort behavior
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
}
|
|
);
|
|
|
|
Log.Debug("Calling InsertWithTxCommit");
|
|
waiting++;
|
|
context.Procedures.InsertWithTxCommit(
|
|
(IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(
|
|
result.IsSuccess,
|
|
$"InsertWithTxCommit should succeed. Error received: {result.Error}"
|
|
);
|
|
var expectedRow = new MyTable(new ReturnStruct(42, "magic"));
|
|
var row = context.Db.MyTable.Iter().FirstOrDefault();
|
|
Debug.Assert(row != null);
|
|
Debug.Assert(row.Equals(expectedRow));
|
|
Log.Debug("Insert with transaction commit succeeded");
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
}
|
|
);
|
|
|
|
Log.Debug("Calling InsertWithTxRetry");
|
|
waiting++;
|
|
context.Procedures.InsertWithTxRetry(
|
|
(IProcedureEventContext ctx, ProcedureCallbackResult<SpacetimeDB.Unit> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(
|
|
result.IsSuccess,
|
|
$"InsertWithTxRetry should succeed after retry. Error received: {result.Error}"
|
|
);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Exception(ex);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
}
|
|
);
|
|
|
|
Log.Debug("Calling TxContextCapabilities");
|
|
waiting++;
|
|
context.Procedures.TxContextCapabilities(
|
|
(IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(
|
|
result.IsSuccess,
|
|
$"TxContextCapabilities should succeed. Error received: {result.Error}"
|
|
);
|
|
Debug.Assert(
|
|
result.Value != null && result.Value.B.StartsWith("sender:"),
|
|
$"Expected sender info, got {result.Value.B}"
|
|
);
|
|
|
|
// Verify the inserted row has the expected data
|
|
var rows = context.Db.MyTable.Iter().ToList();
|
|
var timestampRow = rows.FirstOrDefault(r => r.Field.B.StartsWith("tx-test"));
|
|
Debug.Assert(
|
|
timestampRow != null && timestampRow.Field.A == 200,
|
|
$"Expected field.A == 200, got {timestampRow.Field.A}"
|
|
);
|
|
Debug.Assert(
|
|
timestampRow.Field.B == "tx-test",
|
|
$"Expected field.B == 'tx-test', got {timestampRow.Field.B}"
|
|
);
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
}
|
|
);
|
|
|
|
Log.Debug("Calling AuthenticationCapabilities");
|
|
waiting++;
|
|
context.Procedures.AuthenticationCapabilities(
|
|
(IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(
|
|
result.IsSuccess,
|
|
$"AuthenticationCapabilities should succeed. Error received: {result.Error}"
|
|
);
|
|
Debug.Assert(result.Value != null, "Should return a valid sender-derived value");
|
|
Debug.Assert(
|
|
result.Value.B.Contains("jwt:") || result.Value.B == "no-jwt",
|
|
$"Should return JWT info, got {result.Value.B}"
|
|
);
|
|
|
|
// Verify the inserted row has authentication information
|
|
var rows = context.Db.MyTable.Iter().ToList();
|
|
|
|
var authRow = rows.FirstOrDefault(r => r.Field.B.StartsWith("auth:"));
|
|
Debug.Assert(authRow is not null, "Should have a row with auth data");
|
|
Debug.Assert(
|
|
authRow.Field.B.Contains("sender:"),
|
|
"Auth row should contain sender info"
|
|
);
|
|
Debug.Assert(
|
|
authRow.Field.B.Contains("conn:"),
|
|
"Auth row should contain connection info"
|
|
);
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
}
|
|
);
|
|
|
|
Log.Debug("Calling SubscriptionEventOffset");
|
|
waiting++;
|
|
context.Procedures.SubscriptionEventOffset(
|
|
(IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(
|
|
result.IsSuccess,
|
|
$"SubscriptionEventOffset should succeed. Error received: {result.Error}"
|
|
);
|
|
Debug.Assert(
|
|
result.Value != null && result.Value.A == 999,
|
|
$"Expected A == 999, got {result.Value.A}"
|
|
);
|
|
Debug.Assert(
|
|
result.Value.B.StartsWith("committed:"),
|
|
$"Expected committed timestamp, got {result.Value.B}"
|
|
);
|
|
|
|
// Verify the inserted row has the expected offset test data
|
|
var rows = context.Db.MyTable.Iter().ToList();
|
|
var offsetRow = rows.FirstOrDefault(r => r.Field.B.StartsWith("offset-test:"));
|
|
Debug.Assert(offsetRow is not null, "Should have a row with offset-test data");
|
|
Debug.Assert(offsetRow.Field.A == 999, "Offset test row should have A == 999");
|
|
|
|
// Note: Transaction offset information is not directly accessible in ProcedureEvent,
|
|
// but this test verifies that the transaction was committed and subscription events were generated
|
|
// The presence of the new row in the subscription confirms the transaction offset was processed
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
}
|
|
);
|
|
|
|
Log.Debug("Calling DocumentationGapChecks with valid parameters");
|
|
waiting++;
|
|
context.Procedures.DocumentationGapChecks(
|
|
42,
|
|
"test-input",
|
|
(IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(
|
|
result.IsSuccess,
|
|
"DocumentationGapChecks should succeed with valid parameters"
|
|
);
|
|
|
|
// Expected: inputValue * 2 + inputText.Length = 42 * 2 + 10 = 94
|
|
var expectedValue = 42u * 2 + (uint)"test-input".Length; // 84 + 10 = 94
|
|
Debug.Assert(
|
|
result.Value != null && result.Value.A == expectedValue,
|
|
$"Expected A == {expectedValue}, got {result.Value.A}"
|
|
);
|
|
Debug.Assert(
|
|
result.Value.B.StartsWith("success:"),
|
|
$"Expected success message, got {result.Value.B}"
|
|
);
|
|
Debug.Assert(
|
|
result.Value.B.Contains("test-input"),
|
|
"Result should contain input text"
|
|
);
|
|
|
|
// Verify the inserted row has the expected documentation gap test data
|
|
var rows = context.Db.MyTable.Iter().ToList();
|
|
var docGapRow = rows.FirstOrDefault(r => r.Field.B.StartsWith("doc-gap:"));
|
|
Debug.Assert(docGapRow is not null, "Should have a row with doc-gap data");
|
|
Debug.Assert(
|
|
docGapRow.Field.A == expectedValue,
|
|
$"Doc gap row should have A == {expectedValue}"
|
|
);
|
|
Debug.Assert(
|
|
docGapRow.Field.B.Contains("test-input"),
|
|
"Doc gap row should contain input text"
|
|
);
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
}
|
|
);
|
|
|
|
// Test error handling with invalid parameters
|
|
Log.Debug("Calling DocumentationGapChecks with invalid parameters (should fail)");
|
|
waiting++;
|
|
context.Procedures.DocumentationGapChecks(
|
|
0,
|
|
"",
|
|
(IProcedureEventContext ctx, ProcedureCallbackResult<ReturnStruct> result) =>
|
|
{
|
|
try
|
|
{
|
|
Debug.Assert(
|
|
!result.IsSuccess,
|
|
"DocumentationGapChecks should fail with invalid parameters"
|
|
);
|
|
// TODO: Testing against Rust, this returned a different error type "System.Exception". Decide if this is a bug or not.
|
|
//Debug.Assert(result.Error is ArgumentException, $"Expected ArgumentException, got {result.Error?.GetType()}");
|
|
}
|
|
finally
|
|
{
|
|
waiting--;
|
|
}
|
|
}
|
|
);
|
|
|
|
// The unsubscribe phase clears the subscribed cache.
|
|
// Start it only after the reducer and procedure callbacks above
|
|
// have finished validating rows in that cache.
|
|
mainPhaseScheduled = true;
|
|
}
|
|
|
|
void StartMainUnsubscribe()
|
|
{
|
|
if (mainUnsubscribeStarted)
|
|
{
|
|
return;
|
|
}
|
|
mainUnsubscribeStarted = true;
|
|
|
|
var handle =
|
|
mainHandle
|
|
?? throw new InvalidOperationException("Main subscription handle was not initialised.");
|
|
|
|
// Now unsubscribe and check that the unsubscribing is actually applied.
|
|
Log.Debug("Calling Unsubscribe");
|
|
waiting++;
|
|
handle.UnsubscribeThen(
|
|
(ctx) =>
|
|
{
|
|
Log.Debug("Received Unsubscribe");
|
|
ValidateBTreeIndexes(ctx);
|
|
ExecViewPkOnUpdate();
|
|
waiting--;
|
|
}
|
|
);
|
|
}
|
|
|
|
RegressionTestHarness.RegisterUnhandledExceptionExitHandler();
|
|
db = RegressionTestHarness.ConnectToDatabase(HOST, DBNAME, OnConnected);
|
|
const int TIMEOUT = 20; // seconds;
|
|
RegressionTestHarness.FrameTickUntilComplete(
|
|
db,
|
|
() =>
|
|
{
|
|
if (mainPhaseScheduled && !mainUnsubscribeStarted && waiting == 0)
|
|
{
|
|
StartMainUnsubscribe();
|
|
}
|
|
|
|
return runComplete && waiting == 0;
|
|
},
|
|
TIMEOUT
|
|
);
|
|
Log.Info("Success");
|
|
Environment.Exit(0);
|