diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs index 956ff5e43cf..0527ba3b65e 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs @@ -191,8 +191,7 @@ internal IdentityUniqueIndex() // Important: don't move this to the base class. // C# generics don't play well with nullable types and can't accept both struct-type-based and class-type-based // `globalName` in one generic definition, leading to buggy `Row?` expansion for either one or another. - public global::Player? Find(SpacetimeDB.Identity key) => - DoFilter(key).Cast().SingleOrDefault(); + public global::Player? Find(SpacetimeDB.Identity key) => FindSingle(key); public global::Player Update(global::Player row) => DoUpdate(row); } @@ -302,8 +301,7 @@ internal IdentityFieldUniqueIndex() // Important: don't move this to the base class. // C# generics don't play well with nullable types and can't accept both struct-type-based and class-type-based // `globalName` in one generic definition, leading to buggy `Row?` expansion for either one or another. - public global::TestAutoIncNotInteger? Find(string key) => - DoFilter(key).Cast().SingleOrDefault(); + public global::TestAutoIncNotInteger? Find(string key) => FindSingle(key); public global::TestAutoIncNotInteger Update(global::TestAutoIncNotInteger row) => DoUpdate(row); @@ -766,8 +764,7 @@ internal IdCorrectTypeUniqueIndex() // Important: don't move this to the base class. // C# generics don't play well with nullable types and can't accept both struct-type-based and class-type-based // `globalName` in one generic definition, leading to buggy `Row?` expansion for either one or another. - public global::TestScheduleIssues? Find(int key) => - DoFilter(key).Cast().SingleOrDefault(); + public global::TestScheduleIssues? Find(int key) => FindSingle(key); public global::TestScheduleIssues Update(global::TestScheduleIssues row) => DoUpdate(row); @@ -861,8 +858,7 @@ internal IdWrongTypeUniqueIndex() // Important: don't move this to the base class. // C# generics don't play well with nullable types and can't accept both struct-type-based and class-type-based // `globalName` in one generic definition, leading to buggy `Row?` expansion for either one or another. - public global::TestScheduleIssues? Find(string key) => - DoFilter(key).Cast().SingleOrDefault(); + public global::TestScheduleIssues? Find(string key) => FindSingle(key); public global::TestScheduleIssues Update(global::TestScheduleIssues row) => DoUpdate(row); @@ -956,8 +952,7 @@ internal IdCorrectTypeUniqueIndex() // Important: don't move this to the base class. // C# generics don't play well with nullable types and can't accept both struct-type-based and class-type-based // `globalName` in one generic definition, leading to buggy `Row?` expansion for either one or another. - public global::TestScheduleIssues? Find(int key) => - DoFilter(key).Cast().SingleOrDefault(); + public global::TestScheduleIssues? Find(int key) => FindSingle(key); public global::TestScheduleIssues Update(global::TestScheduleIssues row) => DoUpdate(row); @@ -1058,7 +1053,7 @@ internal PrimaryKeyFieldUniqueIndex() // C# generics don't play well with nullable types and can't accept both struct-type-based and class-type-based // `globalName` in one generic definition, leading to buggy `Row?` expansion for either one or another. public global::TestUniqueNotEquatable? Find(TestEnumWithExplicitValues key) => - DoFilter(key).Cast().SingleOrDefault(); + FindSingle(key); public global::TestUniqueNotEquatable Update(global::TestUniqueNotEquatable row) => DoUpdate(row); 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 ff0cd7c9923..1819c847875 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 @@ -368,8 +368,7 @@ internal IdUniqueIndex() // Important: don't move this to the base class. // C# generics don't play well with nullable types and can't accept both struct-type-based and class-type-based // `globalName` in one generic definition, leading to buggy `Row?` expansion for either one or another. - public global::BTreeViews? Find(SpacetimeDB.Identity key) => - DoFilter(key).Cast().SingleOrDefault(); + public global::BTreeViews? Find(SpacetimeDB.Identity key) => FindSingle(key); public global::BTreeViews Update(global::BTreeViews row) => DoUpdate(row); } @@ -550,8 +549,7 @@ internal FooUniqueIndex() // Important: don't move this to the base class. // C# generics don't play well with nullable types and can't accept both struct-type-based and class-type-based // `globalName` in one generic definition, leading to buggy `Row?` expansion for either one or another. - public global::MultiTableRow? Find(uint key) => - DoFilter(key).Cast().SingleOrDefault(); + public global::MultiTableRow? Find(uint key) => FindSingle(key); public global::MultiTableRow Update(global::MultiTableRow row) => DoUpdate(row); } @@ -669,8 +667,7 @@ internal BarUniqueIndex() // Important: don't move this to the base class. // C# generics don't play well with nullable types and can't accept both struct-type-based and class-type-based // `globalName` in one generic definition, leading to buggy `Row?` expansion for either one or another. - public global::MultiTableRow? Find(uint key) => - DoFilter(key).Cast().SingleOrDefault(); + public global::MultiTableRow? Find(uint key) => FindSingle(key); public global::MultiTableRow Update(global::MultiTableRow row) => DoUpdate(row); } @@ -795,8 +792,7 @@ internal IdUniqueIndex() // Important: don't move this to the base class. // C# generics don't play well with nullable types and can't accept both struct-type-based and class-type-based // `globalName` in one generic definition, leading to buggy `Row?` expansion for either one or another. - public global::PublicTable? Find(int key) => - DoFilter(key).Cast().SingleOrDefault(); + public global::PublicTable? Find(int key) => FindSingle(key); public global::PublicTable Update(global::PublicTable row) => DoUpdate(row); } @@ -903,9 +899,7 @@ internal Unique1UniqueIndex() // C# generics don't play well with nullable types and can't accept both struct-type-based and class-type-based // `globalName` in one generic definition, leading to buggy `Row?` expansion for either one or another. public global::RegressionMultipleUniqueIndexesHadSameName? Find(uint key) => - DoFilter(key) - .Cast() - .SingleOrDefault(); + FindSingle(key); public global::RegressionMultipleUniqueIndexesHadSameName Update( global::RegressionMultipleUniqueIndexesHadSameName row @@ -929,9 +923,7 @@ internal Unique2UniqueIndex() // C# generics don't play well with nullable types and can't accept both struct-type-based and class-type-based // `globalName` in one generic definition, leading to buggy `Row?` expansion for either one or another. public global::RegressionMultipleUniqueIndexesHadSameName? Find(uint key) => - DoFilter(key) - .Cast() - .SingleOrDefault(); + FindSingle(key); public global::RegressionMultipleUniqueIndexesHadSameName Update( global::RegressionMultipleUniqueIndexesHadSameName row @@ -1033,8 +1025,7 @@ internal ScheduledIdUniqueIndex() // Important: don't move this to the base class. // C# generics don't play well with nullable types and can't accept both struct-type-based and class-type-based // `globalName` in one generic definition, leading to buggy `Row?` expansion for either one or another. - public global::Timers.SendMessageTimer? Find(ulong key) => - DoFilter(key).Cast().SingleOrDefault(); + public global::Timers.SendMessageTimer? Find(ulong key) => FindSingle(key); public global::Timers.SendMessageTimer Update(global::Timers.SendMessageTimer row) => DoUpdate(row); diff --git a/crates/bindings-csharp/Codegen/Module.cs b/crates/bindings-csharp/Codegen/Module.cs index 82de41d845b..75d4bd4fbcf 100644 --- a/crates/bindings-csharp/Codegen/Module.cs +++ b/crates/bindings-csharp/Codegen/Module.cs @@ -410,6 +410,8 @@ record TableDeclaration : BaseTypeDeclaration public readonly EquatableArray TableAccessors; public readonly EquatableArray Indexes; + private readonly bool isRowStruct; + public int? GetColumnIndex(AttributeData attrContext, string name, DiagReporter diag) { var index = Members @@ -428,6 +430,8 @@ public TableDeclaration(GeneratorAttributeSyntaxContext context, DiagReporter di { var typeSyntax = (TypeDeclarationSyntax)context.TargetNode; + isRowStruct = ((INamedTypeSymbol)context.TargetSymbol).IsValueType; + if (Kind is TypeKind.Sum) { diag.Report(ErrorDescriptor.TableTaggedEnum, typeSyntax); @@ -481,6 +485,8 @@ public IEnumerable GenerateTableAccessorFilters(TableAccessor tableAcces var vis = SyntaxFacts.GetText(Visibility); var globalName = $"global::{FullName}"; + var uniqueIndexBase = isRowStruct ? "UniqueIndex" : "RefUniqueIndex"; + foreach (var ct in GetConstraints(tableAccessor, ColumnAttrs.Unique)) { var f = ct.Col; @@ -492,12 +498,12 @@ public IEnumerable GenerateTableAccessorFilters(TableAccessor tableAcces } var standardIndexName = ct.ToIndex().StandardIndexName(tableAccessor); yield return $$""" - {{vis}} sealed class {{f.Name}}UniqueIndex : UniqueIndex<{{tableAccessor.Name}}, {{globalName}}, {{f.Type.Name}}, {{f.Type.BSATNName}}> { + {{vis}} sealed class {{f.Name}}UniqueIndex : {{uniqueIndexBase}}<{{tableAccessor.Name}}, {{globalName}}, {{f.Type.Name}}, {{f.Type.BSATNName}}> { internal {{f.Name}}UniqueIndex() : base("{{standardIndexName}}") {} // Important: don't move this to the base class. // C# generics don't play well with nullable types and can't accept both struct-type-based and class-type-based // `globalName` in one generic definition, leading to buggy `Row?` expansion for either one or another. - public {{globalName}}? Find({{f.Type.Name}} key) => DoFilter(key).Cast<{{globalName}}?>().SingleOrDefault(); + public {{globalName}}? Find({{f.Type.Name}} key) => FindSingle(key); public {{globalName}} Update({{globalName}} row) => DoUpdate(row); } {{vis}} {{f.Name}}UniqueIndex {{f.Name}} => new(); @@ -570,6 +576,10 @@ private IEnumerable GenerateReadOnlyAccessorFilters(TableAccessor tableA var vis = SyntaxFacts.GetText(Visibility); var globalName = $"global::{FullName}"; + var uniqueIndexBase = isRowStruct + ? "global::SpacetimeDB.Internal.ReadOnlyUniqueIndex" + : "global::SpacetimeDB.Internal.ReadOnlyRefUniqueIndex"; + foreach (var ct in GetConstraints(tableAccessor, ColumnAttrs.Unique)) { var f = ct.Col; @@ -582,7 +592,7 @@ private IEnumerable GenerateReadOnlyAccessorFilters(TableAccessor tableA yield return $$$""" public sealed class {{{f.Name}}}Index - : global::SpacetimeDB.Internal.ReadOnlyUniqueIndex< + : {{{uniqueIndexBase}}}< global::SpacetimeDB.Internal.ViewHandles.{{{tableAccessor.Name}}}ReadOnly, {{{globalName}}}, {{{f.Type.Name}}}, diff --git a/crates/bindings-csharp/Runtime/Internal/FFI.cs b/crates/bindings-csharp/Runtime/Internal/FFI.cs index 565fc3d9611..dec9c8f25df 100644 --- a/crates/bindings-csharp/Runtime/Internal/FFI.cs +++ b/crates/bindings-csharp/Runtime/Internal/FFI.cs @@ -81,6 +81,14 @@ internal static partial class FFI #endif ; + const string StdbNamespace10_4 = +#if EXPERIMENTAL_WASM_AOT + "spacetime_10.4" +#else + "bindings" +#endif + ; + [NativeMarshalling(typeof(Marshaller))] public struct CheckedStatus { @@ -195,6 +203,22 @@ public static partial CheckedStatus datastore_table_scan_bsatn( out RowIter out_ ); + [LibraryImport(StdbNamespace10_4)] + public static partial CheckedStatus datastore_index_scan_point_bsatn( + IndexId index_id, + ReadOnlySpan point, + uint point_len, + out RowIter out_ + ); + + [LibraryImport(StdbNamespace10_4)] + public static partial CheckedStatus datastore_delete_by_index_scan_point_bsatn( + IndexId index_id, + ReadOnlySpan point, + uint point_len, + out uint out_ + ); + [LibraryImport(StdbNamespace10_0)] public static partial CheckedStatus datastore_index_scan_range_bsatn( IndexId index_id, diff --git a/crates/bindings-csharp/Runtime/Internal/IIndex.cs b/crates/bindings-csharp/Runtime/Internal/IIndex.cs index 4e7d0cdaf9e..33525924f2d 100644 --- a/crates/bindings-csharp/Runtime/Internal/IIndex.cs +++ b/crates/bindings-csharp/Runtime/Internal/IIndex.cs @@ -1,4 +1,4 @@ -namespace SpacetimeDB.Internal; +namespace SpacetimeDB.Internal; using System; using System.Collections.Generic; @@ -94,14 +94,118 @@ protected IEnumerable Filter(Bounds bounds) public abstract class UniqueIndex(string name) : IndexBase(name) where Handle : ITableView - where Row : IStructuralReadWrite, new() + where Row : struct, IStructuralReadWrite where RW : struct, BSATN.IReadWrite { private static BTreeIndexBounds ToBounds(T key) => new(key); + private sealed class RawPointIter(FFI.IndexId indexId, byte[] point) : RawTableIterBase + { + protected override void IterStart(out FFI.RowIter handle) => + FFI.datastore_index_scan_point_bsatn(indexId, point, (uint)point.Length, out handle); + } + protected IEnumerable DoFilter(T key) => DoFilter(ToBounds(key)); - public bool Delete(T key) => DoDelete(ToBounds(key)) > 0; + public bool Delete(T key) + { + using var s = new MemoryStream(); + using var w = new BinaryWriter(s); + new RW().Write(w, key); + var point = s.ToArray(); + FFI.datastore_delete_by_index_scan_point_bsatn( + indexId, + point, + (uint)point.Length, + out var numDeleted + ); + return numDeleted > 0; + } + + protected Row? FindSingle(T key) + { + using var s = new MemoryStream(); + using var w = new BinaryWriter(s); + new RW().Write(w, key); + var point = s.ToArray(); + + using var e = new RawPointIter(indexId, point).Parse().GetEnumerator(); + if (!e.MoveNext()) + { + return null; + } + + var row = e.Current; + if (e.MoveNext()) + { + throw new InvalidOperationException("Unique index point scan returned >1 rows"); + } + + return row; + } + + protected Row DoUpdate(Row row) + { + // Insert the row. + var bytes = IStructuralReadWrite.ToBytes(row); + var bytes_len = (uint)bytes.Length; + FFI.datastore_update_bsatn(ITableView.tableId, indexId, bytes, ref bytes_len); + + return ITableView.IntegrateGeneratedColumns(row, bytes, bytes_len); + } +} + +public abstract class RefUniqueIndex(string name) : IndexBase(name) + where Handle : ITableView + where Row : class, IStructuralReadWrite, new() + where RW : struct, BSATN.IReadWrite +{ + private static BTreeIndexBounds ToBounds(T key) => new(key); + + private sealed class RawPointIter(FFI.IndexId indexId, byte[] point) : RawTableIterBase + { + protected override void IterStart(out FFI.RowIter handle) => + FFI.datastore_index_scan_point_bsatn(indexId, point, (uint)point.Length, out handle); + } + + protected IEnumerable DoFilter(T key) => DoFilter(ToBounds(key)); + + public bool Delete(T key) + { + using var s = new MemoryStream(); + using var w = new BinaryWriter(s); + new RW().Write(w, key); + var point = s.ToArray(); + FFI.datastore_delete_by_index_scan_point_bsatn( + indexId, + point, + (uint)point.Length, + out var numDeleted + ); + return numDeleted > 0; + } + + protected Row? FindSingle(T key) + { + using var s = new MemoryStream(); + using var w = new BinaryWriter(s); + new RW().Write(w, key); + var point = s.ToArray(); + + using var e = new RawPointIter(indexId, point).Parse().GetEnumerator(); + if (!e.MoveNext()) + { + return null; + } + + var row = e.Current; + if (e.MoveNext()) + { + throw new InvalidOperationException("Unique index point scan returned >1 rows"); + } + + return row; + } protected Row DoUpdate(Row row) { @@ -117,14 +221,79 @@ protected Row DoUpdate(Row row) public abstract class ReadOnlyUniqueIndex(string name) : ReadOnlyIndexBase(name) where Handle : ReadOnlyTableView - where Row : IStructuralReadWrite, new() + where Row : struct, IStructuralReadWrite + where RW : struct, BSATN.IReadWrite +{ + private static BTreeIndexBounds ToBounds(T key) => new(key); + + private sealed class RawPointIter(FFI.IndexId indexId, byte[] point) : RawTableIterBase + { + protected override void IterStart(out FFI.RowIter handle) => + FFI.datastore_index_scan_point_bsatn(indexId, point, (uint)point.Length, out handle); + } + + protected IEnumerable Filter(T key) => Filter(ToBounds(key)); + + protected Row? FindSingle(T key) + { + using var s = new MemoryStream(); + using var w = new BinaryWriter(s); + new RW().Write(w, key); + var point = s.ToArray(); + + using var e = new RawPointIter(indexId, point).Parse().GetEnumerator(); + if (!e.MoveNext()) + { + return null; + } + + var row = e.Current; + if (e.MoveNext()) + { + throw new InvalidOperationException("Unique index point scan returned >1 rows"); + } + + return row; + } +} + +public abstract class ReadOnlyRefUniqueIndex(string name) + : ReadOnlyIndexBase(name) + where Handle : ReadOnlyTableView + where Row : class, IStructuralReadWrite, new() where RW : struct, BSATN.IReadWrite { private static BTreeIndexBounds ToBounds(T key) => new(key); + private sealed class RawPointIter(FFI.IndexId indexId, byte[] point) : RawTableIterBase + { + protected override void IterStart(out FFI.RowIter handle) => + FFI.datastore_index_scan_point_bsatn(indexId, point, (uint)point.Length, out handle); + } + protected IEnumerable Filter(T key) => Filter(ToBounds(key)); - protected Row? FindSingle(T key) => Filter(key).Cast().SingleOrDefault(); + protected Row? FindSingle(T key) + { + using var s = new MemoryStream(); + using var w = new BinaryWriter(s); + new RW().Write(w, key); + var point = s.ToArray(); + + using var e = new RawPointIter(indexId, point).Parse().GetEnumerator(); + if (!e.MoveNext()) + { + return null; + } + + var row = e.Current; + if (e.MoveNext()) + { + throw new InvalidOperationException("Unique index point scan returned >1 rows"); + } + + return row; + } } public abstract class ReadOnlyTableView diff --git a/crates/bindings-csharp/Runtime/Internal/ITable.cs b/crates/bindings-csharp/Runtime/Internal/ITable.cs index 575c3a9fc10..8855b36b4d8 100644 --- a/crates/bindings-csharp/Runtime/Internal/ITable.cs +++ b/crates/bindings-csharp/Runtime/Internal/ITable.cs @@ -20,11 +20,16 @@ public bool MoveNext() uint buffer_len; while (true) { - buffer_len = (uint)buffer.Length; + var requested_len = (uint)buffer.Length; + buffer_len = requested_len; var ret = FFI.row_iter_bsatn_advance(handle, buffer, ref buffer_len); if (ret == Errno.EXHAUSTED) { handle = FFI.RowIter.INVALID; + if (buffer_len == requested_len) + { + buffer_len = 0; + } } // On success, the only way `buffer_len == 0` is for the iterator to be exhausted. // This happens when the host iterator was empty from the start. diff --git a/crates/bindings-csharp/Runtime/bindings.c b/crates/bindings-csharp/Runtime/bindings.c index 6ea4394f21a..33fa039fa4d 100644 --- a/crates/bindings-csharp/Runtime/bindings.c +++ b/crates/bindings-csharp/Runtime/bindings.c @@ -101,6 +101,15 @@ IMPORT(void, volatile_nonatomic_schedule_immediate, IMPORT(void, identity, (void* id_ptr), (id_ptr)); #undef SPACETIME_MODULE_VERSION +#define SPACETIME_MODULE_VERSION "spacetime_10.4" +IMPORT(Status, datastore_index_scan_point_bsatn, + (IndexId index_id, const uint8_t* point, uint32_t point_len, RowIter* iter), + (index_id, point, point_len, iter)); +IMPORT(Status, datastore_delete_by_index_scan_point_bsatn, + (IndexId index_id, const uint8_t* point, uint32_t point_len, uint32_t* num_deleted), + (index_id, point, point_len, num_deleted)); +#undef SPACETIME_MODULE_VERSION + #define SPACETIME_MODULE_VERSION "spacetime_10.1" IMPORT(int16_t, bytes_source_remaining_length, (BytesSource source, uint32_t* out), (source, out)); #undef SPACETIME_MODULE_VERSION