From 04b02419dfeb65f294f76dcd41dd095aecdd3386 Mon Sep 17 00:00:00 2001 From: luke Date: Mon, 4 May 2026 21:54:35 +0100 Subject: [PATCH 1/3] feat: support option values in Rust index filters --- crates/lib/src/filterable_value.rs | 5 +++++ modules/module-test/src/lib.rs | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/lib/src/filterable_value.rs b/crates/lib/src/filterable_value.rs index ac7c0b52a41..2f22754050a 100644 --- a/crates/lib/src/filterable_value.rs +++ b/crates/lib/src/filterable_value.rs @@ -118,6 +118,11 @@ impl_filterable_value! { // &[u8] => Vec, } +impl Private for Option {} +impl FilterableValue for Option { + type Column = Option; +} + pub enum TermBound { Single(ops::Bound), Range(ops::Bound, ops::Bound), diff --git a/modules/module-test/src/lib.rs b/modules/module-test/src/lib.rs index fe1d49169ce..c98e2d07ff4 100644 --- a/modules/module-test/src/lib.rs +++ b/modules/module-test/src/lib.rs @@ -89,6 +89,14 @@ pub struct TestE { name: String, } +#[spacetimedb::table(accessor = option_index_args)] +pub struct OptionIndexArgs { + #[primary_key] + id: u64, + #[index(btree)] + option_u64: Option, +} + #[derive(SpacetimeType)] pub struct Baz { pub field: String, @@ -445,6 +453,15 @@ fn test_btree_index_args(ctx: &ReducerContext) { // ctx.db.test_e().name().delete(string); // SHOULD FAIL + // Single-column Option index on `option_index_args.option_u64`: + // Tests that we can filter by both variants and by option ranges. + let some_u64 = Some(55u64); + let none_u64: Option = None; + let _ = ctx.db.option_index_args().option_u64().filter(some_u64); + let _ = ctx.db.option_index_args().option_u64().filter(none_u64); + let _ = ctx.db.option_index_args().option_u64().filter(Some(1u64)..Some(99u64)); + let _ = ctx.db.option_index_args().option_u64().filter(None..=Some(99u64)); + // Multi-column i64 index on `points.x, points.y`: // Tests that we can pass various ranges // and various combinations of borrowed/owned `Copy` values. From 35573bb72cc66833681f1db415c73a4c1349c31a Mon Sep 17 00:00:00 2001 From: luke Date: Mon, 4 May 2026 21:57:26 +0100 Subject: [PATCH 2/3] fix(ts): compare option values in btree cache filters --- .../src/sdk/table_cache.ts | 21 +++++-- .../table_cache_resolved_indexes.test.ts | 58 +++++++++++++++++++ 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/crates/bindings-typescript/src/sdk/table_cache.ts b/crates/bindings-typescript/src/sdk/table_cache.ts index 42645daaecf..1b419ff4caa 100644 --- a/crates/bindings-typescript/src/sdk/table_cache.ts +++ b/crates/bindings-typescript/src/sdk/table_cache.ts @@ -37,13 +37,22 @@ export type PendingCallback = { cb: () => void; }; -// Strict scalar compare for index term values. -const scalarCompare = (x: any, y: any): number => { +const isOptionNone = (value: any): boolean => + value === null || value === undefined; + +const compareIndexTerm = (x: any, y: any): number => { + if (isOptionNone(x) && isOptionNone(y)) return 0; + if (isOptionNone(x)) return -1; + if (isOptionNone(y)) return 1; if (x === y) return 0; + if (typeof x?.compareTo === 'function') return x.compareTo(y); // Compare booleans/numbers/bigints/strings with JS ordering. return x < y ? -1 : 1; }; +const indexTermEqual = (x: any, y: any): boolean => + compareIndexTerm(x, y) === 0 || deepEqual(x, y); + export type TableIndexView< RemoteModule extends UntypedRemoteModule, TableName extends TableNamesOf, @@ -142,7 +151,7 @@ export class TableCacheImpl< const prefixLen = Math.max(0, arr.length - 1); // Check equality over the prefix (all but the last provided element) for (let i = 0; i < prefixLen; i++) { - if (!deepEqual(key[i], arr[i])) return false; + if (!indexTermEqual(key[i], arr[i])) return false; } const lastProvided = arr[arr.length - 1]; @@ -161,14 +170,14 @@ export class TableCacheImpl< // Lower bound if (from.tag !== 'unbounded') { - const c = scalarCompare(kLast, from.value); + const c = compareIndexTerm(kLast, from.value); if (c < 0) return false; if (c === 0 && from.tag === 'excluded') return false; } // Upper bound if (to.tag !== 'unbounded') { - const c = scalarCompare(kLast, to.value); + const c = compareIndexTerm(kLast, to.value); if (c > 0) return false; if (c === 0 && to.tag === 'excluded') return false; } @@ -178,7 +187,7 @@ export class TableCacheImpl< return true; } else { // Equality on the last provided element - if (!deepEqual(kLast, lastProvided)) return false; + if (!indexTermEqual(kLast, lastProvided)) return false; // Any remaining columns are unconstrained (prefix equality only). return true; } diff --git a/crates/bindings-typescript/tests/table_cache_resolved_indexes.test.ts b/crates/bindings-typescript/tests/table_cache_resolved_indexes.test.ts index 1e67cb418e5..bb96e903ca5 100644 --- a/crates/bindings-typescript/tests/table_cache_resolved_indexes.test.ts +++ b/crates/bindings-typescript/tests/table_cache_resolved_indexes.test.ts @@ -3,6 +3,7 @@ import { ModuleContext, tablesToSchema } from '../src/lib/schema'; import { table } from '../src/lib/table'; import { TableCacheImpl } from '../src/sdk/table_cache'; import { t } from '../src/lib/type_builders'; +import { Range } from '../src/server/range'; describe('table cache resolved indexes', () => { it('builds index accessors from resolvedIndexes (field-level + table-level)', () => { @@ -68,4 +69,61 @@ describe('table cache resolved indexes', () => { expect(typeof byTeamAndLevel?.filter).toBe('function'); expect(Array.from(byTeamAndLevel.filter(['red', 1]))).toEqual([rows[0]]); }); + + it('treats null and undefined as option none in btree cache filters', () => { + const account = table( + { + name: 'account', + indexes: [ + { + accessor: 'linkedId', + algorithm: 'btree', + columns: ['linkedId'] as const, + }, + ] as const, + }, + { + id: t.u32(), + linkedId: t.option(t.u32()), + } + ); + + const schemaDef = tablesToSchema(new ModuleContext(), { account }); + const accountDef = schemaDef.tables.account; + const tableCache = new TableCacheImpl(accountDef as any); + + const rows = [ + { id: 1, linkedId: undefined }, + { id: 2, linkedId: null }, + { id: 3, linkedId: 5 }, + { id: 4, linkedId: 9 }, + ]; + + const callbacks = tableCache.applyOperations( + rows.map(row => ({ + type: 'insert' as const, + rowId: row.id, + row, + })), + {} + ); + callbacks.forEach(cb => cb.cb()); + + const linkedId = (tableCache as any).linkedId; + + expect(Array.from(linkedId.filter(null)).map(row => row.id)).toEqual([ + 1, 2, + ]); + expect(Array.from(linkedId.filter(5)).map(row => row.id)).toEqual([3]); + expect( + Array.from( + linkedId.filter( + new Range( + { tag: 'included', value: null }, + { tag: 'included', value: 5 } + ) + ) + ).map(row => row.id) + ).toEqual([1, 2, 3]); + }); }); From 04b404ed4b29c6e9f7428433923eb6cf9cad0522 Mon Sep 17 00:00:00 2001 From: luke Date: Mon, 4 May 2026 21:58:39 +0100 Subject: [PATCH 3/3] test(csharp): cover nullable btree index generation --- crates/bindings-csharp/Codegen.Tests/Tests.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/bindings-csharp/Codegen.Tests/Tests.cs b/crates/bindings-csharp/Codegen.Tests/Tests.cs index f3933229c16..31a3bb4c245 100644 --- a/crates/bindings-csharp/Codegen.Tests/Tests.cs +++ b/crates/bindings-csharp/Codegen.Tests/Tests.cs @@ -347,6 +347,55 @@ public static void @params(ProcedureContext ctx) Assert.Empty(GetCompilationErrors(compilationAfterGen)); } + [Fact] + public static async Task NullableBTreeIndexesCompile() + { + var fixture = await Fixture.Compile("server"); + + const string source = """ + using SpacetimeDB; + + [SpacetimeDB.Table] + public partial struct NullableBTreeIndex + { + [SpacetimeDB.PrimaryKey] + public uint Id; + + [SpacetimeDB.Index.BTree] + public uint? AccountId; + + [SpacetimeDB.Reducer] + public static void TestNullableBTreeIndex(ReducerContext ctx) + { + _ = ctx.Db.NullableBTreeIndex.AccountId.Filter((uint?)null); + _ = ctx.Db.NullableBTreeIndex.AccountId.Filter((uint?)55); + _ = ctx.Db.NullableBTreeIndex.AccountId.Filter(new Bound(null, 99)); + } + } + """; + + var parseOptions = new CSharpParseOptions(fixture.SampleCompilation.LanguageVersion); + var tree = CSharpSyntaxTree.ParseText(source, parseOptions, path: "NullableBTreeIndex.cs"); + var compilation = fixture.SampleCompilation.AddSyntaxTrees(tree); + + var driver = CSharpGeneratorDriver.Create( + [ + new SpacetimeDB.Codegen.Type().AsSourceGenerator(), + new SpacetimeDB.Codegen.Module().AsSourceGenerator(), + ], + driverOptions: new( + disabledOutputs: IncrementalGeneratorOutputKind.None, + trackIncrementalGeneratorSteps: true + ), + parseOptions: parseOptions + ); + + var runResult = driver.RunGenerators(compilation).GetRunResult(); + var compilationAfterGen = compilation.AddSyntaxTrees(runResult.GeneratedTrees); + + Assert.Empty(GetCompilationErrors(compilationAfterGen)); + } + [Fact] public static async Task TestDiagnostics() {