Files
rekhoff a546bc8961 Rekhoff/csharp default field values (#3235)
# Description of Changes

This is the implementation of issue #3191. This adds a Default attribute
to C# module fields.

**Note**: In C#, attribute arguments must be compile-time constants,
which means you can't directly use non-constant expressions like new
expressions, method calls, or dynamic values in attribute constructors.
(Ref:
https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/attributes#2324-attribute-parameter-types)

For this reason, these default values are limited to primitive types,
enums, and strings.
This includes (shown as `C# Type` (`BSATN type`):

* `bool` (`Bool`)
* `sbyte` (`I8`)
* `byte` (`U8`)
* `short` (`I16`)
* `ushort` (`U16`)
* `int` (`I32`)
* `unit` (`U32`)
* `long` (`I64`)
* `ulong` (`U64`)
* `float` (`F32`)
* `double` (`F64`)
* `enum` (`Enum`)
* `string` (`String`)
* `null` (`RefOption`) <- Nullable type

Because of C# limitations, for nullable and complex data types, such as
a struct, can take use `[Default(null)]` to populate values with null
defaults. This allows things like structs to workaround the non-constant
expressions in attribute constructors limitation by allowing these
complex types to still be able to be added as new column tables.

The `int` type can also be in the form of Hex or Binary literals, such
as `[Default(0x2A)]` or `[Default(0b00101010)]`

Both Decimal (like `[Default(3.14m)]`) and Char (like `[Default('A')]`)
are unsupported types in BSATN and will still return `BSATN0001` errors.

# API and ABI breaking changes

Not API breaking.
This change only adds the `[Default(value)]` attribute logic.
Using the `[Default(value)]` attribute with older versions SpacetimeDB
C# modules will result in an error.

# Expected complexity level and risk

2

# Testing

Local testing of this requires use of CLI changes in #3278 

- [x] Regression test of functionality added.
2025-10-06 16:56:37 +00:00

151 lines
6.3 KiB
C#

/// Regression tests run with a live server.
/// To run these, run a local SpacetimeDB via `spacetime start`,
/// then in a separate terminal run `tools~/run-regression-tests.sh PATH_TO_SPACETIMEDB_REPO_CHECKOUT`.
/// This is done on CI in .github/workflows/test.yml.
using System.Diagnostics;
using System.Runtime.CompilerServices;
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)
.WithModuleName(DBNAME)
.OnConnect(OnConnected)
.OnConnectError((err) =>
{
throw err;
})
.OnDisconnect((conn, err) =>
{
if (err != null)
{
throw err;
}
else
{
throw new Exception("Unexpected disconnect");
}
})
.Build();
return conn;
}
uint waiting = 0;
bool applied = false;
SubscriptionHandle? handle = null;
void OnConnected(DbConnection conn, Identity identity, string authToken)
{
Log.Debug($"Connected to {DBNAME} on {HOST}");
handle = conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.OnError((ctx, err) =>
{
throw err;
})
.Subscribe(["SELECT * FROM ExampleData"]);
}
void OnSubscriptionApplied(SubscriptionEventContext context)
{
applied = true;
// Do some operations that alter row state;
// we will check that everything is in sync in the callbacks for these reducer calls.
var TOLERANCE = 0.00001f;
foreach (var exampleData in context.Db.ExampleData.Iter())
{
if (exampleData.TestPass == 1)
{
List<string> errors = new List<string>();
// This row should have had values set by default Attributes
if (exampleData.DefaultString != "This is a default string") { errors.Add("DefaultString"); }
if (exampleData.DefaultBool != true) { errors.Add("DefaultBool"); }
if (exampleData.DefaultI8 != 2) { errors.Add("DefaultI8"); }
if (exampleData.DefaultU8 != 2) { errors.Add("DefaultU8"); }
if (exampleData.DefaultI16 != 2) { errors.Add("DefaultI16"); }
if (exampleData.DefaultU16 != 2) { errors.Add("DefaultU16"); }
if (exampleData.DefaultI32 != 2) { errors.Add("DefaultI32"); }
if (exampleData.DefaultU32 != 2) { errors.Add("DefaultU32"); }
if (exampleData.DefaultI64 != 2) { errors.Add("DefaultI64"); }
if (exampleData.DefaultU64 != 2) { errors.Add("DefaultU64"); }
if (exampleData.DefaultHex != 2) { errors.Add("DefaultHex"); }
if (exampleData.DefaultBin != 2) { errors.Add("DefaultBin"); }
if (Math.Abs(exampleData.DefaultF32 - 2.0f) > TOLERANCE) { errors.Add("DefaultF32"); }
if (Math.Abs(exampleData.DefaultF64 - 2.0) > TOLERANCE) { errors.Add("DefaultF64"); }
if (exampleData.DefaultEnum != MyEnum.SetByAttribute) { errors.Add("DefaultEnum"); }
if (exampleData.DefaultNull != null) { errors.Add("DefaultNull"); }
if (errors.Count > 0)
{
var errorString = string.Join(", ", errors);
Log.Info($"ExampleData with key {exampleData.Primary}: Error: Key added during initial test pass, newly added rows {errorString} were not set by default attributes");
}
else
{
Log.Info($"ExampleData with key {exampleData.Primary}: Success! Key added during initial test pass, newly added rows are all properly set by default attributes");
}
}
else if (exampleData.TestPass == 2)
{
List<string> errors = new List<string>();
// This row should have had values set by initialized values
if (exampleData.DefaultString != "") { errors.Add("DefaultString"); }
if (exampleData.DefaultBool != false) { errors.Add("DefaultBool"); }
if (exampleData.DefaultI8 != 1) { errors.Add("DefaultI8"); }
if (exampleData.DefaultU8 != 1) { errors.Add("DefaultU8"); }
if (exampleData.DefaultI16 != 1) { errors.Add("DefaultI16"); }
if (exampleData.DefaultU16 != 1) { errors.Add("DefaultU16"); }
if (exampleData.DefaultI32 != 1) { errors.Add("DefaultI32"); }
if (exampleData.DefaultU32 != 1) { errors.Add("DefaultU32"); }
if (exampleData.DefaultI64 != 1) { errors.Add("DefaultI64"); }
if (exampleData.DefaultU64 != 1) { errors.Add("DefaultU64"); }
if (exampleData.DefaultHex != 1) { errors.Add("DefaultHex"); }
if (exampleData.DefaultBin != 1) { errors.Add("DefaultBin"); }
if (Math.Abs(exampleData.DefaultF32 - 1.0f) > TOLERANCE) { errors.Add("DefaultF32"); }
if (Math.Abs(exampleData.DefaultF64 - 1.0) > TOLERANCE) { errors.Add("DefaultF64"); }
if (exampleData.DefaultEnum != MyEnum.SetByInitalization) { errors.Add("DefaultEnum"); }
if (exampleData.DefaultNull == null || exampleData.DefaultNull.X != 1) { errors.Add("DefaultNull"); }
if (errors.Count > 0)
{
var errorString = string.Join(", ", errors);
Log.Info($"ExampleData with key {exampleData.Primary}: Error: Key added after republishing, newly added rows {errorString} were not set by initialized values");
}
else
{
Log.Error($"ExampleData with key {exampleData.Primary}: Success! Key added after republishing, newly added rows are all properly set by initialized values");
}
}
}
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");
const int TIMEOUT = 20; // seconds;
var start = DateTime.Now;
while (!applied || waiting > 0)
{
db.FrameTick();
Thread.Sleep(100);
if ((DateTime.Now - start).Seconds > TIMEOUT)
{
Log.Error($"Timeout, all events should have elapsed in {TIMEOUT} seconds!");
Environment.Exit(1);
}
}
Log.Info("Success");
Environment.Exit(0);