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() { diff --git a/crates/bindings-typescript/src/lib/type_builders.test-d.ts b/crates/bindings-typescript/src/lib/type_builders.test-d.ts index d0595f25829..6e690b969f6 100644 --- a/crates/bindings-typescript/src/lib/type_builders.test-d.ts +++ b/crates/bindings-typescript/src/lib/type_builders.test-d.ts @@ -43,6 +43,27 @@ const _rowOptionOptionalSome: RowOptionOptional = { foo: 'hello', }; +// Optional columns whose inner type is filterable may be indexed and unique. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const rowOptionIndex = { + id: t.u64(), + optionalId: t.option(t.u64()).index('btree').unique(), +}; +type RowOptionIndex = InferTypeOfRow; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _rowOptionIndexNone: RowOptionIndex = { + id: 1n, + optionalId: undefined, +}; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const _rowOptionIndexSome: RowOptionIndex = { + id: 2n, + optionalId: 1n, +}; + +// @ts-expect-error optional arrays are not filterable and cannot be indexed. +t.option(t.array(t.u64())).index('btree'); + // Test that a row must not allow non-TypeBuilder or ColumnBuilder values // eslint-disable-next-line @typescript-eslint/no-unused-vars const row2 = { diff --git a/crates/bindings-typescript/src/lib/type_builders.ts b/crates/bindings-typescript/src/lib/type_builders.ts index 6bf8ae1ec99..26d7c3593ff 100644 --- a/crates/bindings-typescript/src/lib/type_builders.ts +++ b/crates/bindings-typescript/src/lib/type_builders.ts @@ -287,6 +287,18 @@ interface Indexable< ): ColumnBuilder>; } +type IndexableBuilder> = Value & + Indexable< + InferTypeOfTypeBuilder, + InferSpacetimeTypeOfTypeBuilder + >; + +type UniqueableBuilder> = Value & + Uniqueable< + InferTypeOfTypeBuilder, + InferSpacetimeTypeOfTypeBuilder + >; + /** * Interface for types that can be converted into a column builder with auto-increment metadata. * @@ -1340,6 +1352,36 @@ export class OptionBuilder> super(Option.getAlgebraicType(value.algebraicType)); this.value = value; } + index( + this: OptionBuilder> + ): OptionColumnBuilder< + Value, + SetField + >; + index>( + this: OptionBuilder>, + algorithm: N + ): OptionColumnBuilder>; + index( + this: OptionBuilder>, + algorithm: IndexTypes = 'btree' + ): OptionColumnBuilder< + Value, + SetField + > { + return new OptionColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); + } + unique( + this: OptionBuilder> + ): OptionColumnBuilder> { + return new OptionColumnBuilder( + this, + set(defaultMetadata, { isUnique: true }) + ); + } default( value: InferTypeOfTypeBuilder | undefined ): OptionColumnBuilder< @@ -3125,6 +3167,30 @@ export class OptionColumnBuilder< OptionAlgebraicType> > { + index( + this: OptionColumnBuilder, M> + ): OptionColumnBuilder>; + index>( + this: OptionColumnBuilder, M>, + algorithm: N + ): OptionColumnBuilder>; + index( + this: OptionColumnBuilder, M>, + algorithm: IndexTypes = 'btree' + ): OptionColumnBuilder> { + return new OptionColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); + } + unique( + this: OptionColumnBuilder, M> + ): OptionColumnBuilder> { + return new OptionColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); + } default( value: InferTypeOfTypeBuilder | undefined ): OptionColumnBuilder< diff --git a/crates/bindings-typescript/src/sdk/table_cache.ts b/crates/bindings-typescript/src/sdk/table_cache.ts index 42645daaecf..83b24962735 100644 --- a/crates/bindings-typescript/src/sdk/table_cache.ts +++ b/crates/bindings-typescript/src/sdk/table_cache.ts @@ -37,13 +37,29 @@ 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); + +const indexKeyEqual = ( + actual: readonly unknown[], + expected: readonly unknown[] +): boolean => + actual.length === expected.length && + actual.every((value, i) => indexTermEqual(value, expected[i])); + export type TableIndexView< RemoteModule extends UntypedRemoteModule, TableName extends TableNamesOf, @@ -142,7 +158,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 +177,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 +194,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; } @@ -200,7 +216,7 @@ export class TableCacheImpl< // For unique btree, caller supplies the *full* key (tuple if multi-col). const expected = Array.isArray(colVal) ? colVal : [colVal]; for (const row of self.iter()) { - if (deepEqual(getKey(row), expected)) return row; + if (indexKeyEqual(getKey(row), expected)) return row; } return null; }, 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..28aeeaa8e0f 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,64 @@ 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()).index('btree'), + uniqueLinkedId: t.option(t.u32()).unique(), + } + ); + + const schemaDef = tablesToSchema(new ModuleContext(), { account }); + const accountDef = schemaDef.tables.account; + const tableCache = new TableCacheImpl(accountDef as any); + + const rows = [ + { id: 1, linkedId: undefined, uniqueLinkedId: undefined }, + { id: 2, linkedId: null, uniqueLinkedId: 7 }, + { id: 3, linkedId: 5, uniqueLinkedId: 8 }, + { id: 4, linkedId: 9, uniqueLinkedId: 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; + const uniqueLinkedId = (tableCache as any).uniqueLinkedId; + + expect(uniqueLinkedId.find(null)?.id).toEqual(1); + 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]); + }); }); diff --git a/crates/bindings/tests/pass/option-index-filter.rs b/crates/bindings/tests/pass/option-index-filter.rs new file mode 100644 index 00000000000..ee719f77f32 --- /dev/null +++ b/crates/bindings/tests/pass/option-index-filter.rs @@ -0,0 +1,37 @@ +#[spacetimedb::table(accessor = option_index_args)] +struct OptionIndexArgs { + #[primary_key] + id: u64, + #[index(btree)] + option_u64: Option, +} + +#[spacetimedb::table(accessor = compound_option_index_args, index(accessor = by_id_and_option, btree(columns = [id, option_u64])))] +struct CompoundOptionIndexArgs { + id: u64, + option_u64: Option, +} + +#[spacetimedb::reducer] +fn option_index_filters_compile(ctx: &spacetimedb::ReducerContext) { + 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)); + + let _ = ctx + .db + .compound_option_index_args() + .by_id_and_option() + .filter((1u64, Some(55u64))); + let _ = ctx + .db + .compound_option_index_args() + .by_id_and_option() + .filter((1u64, None..=Some(99u64))); +} + +fn main() {} diff --git a/crates/bindings/tests/ui.rs b/crates/bindings/tests/ui.rs index 870c2f95ec1..c13cfdfaeb2 100644 --- a/crates/bindings/tests/ui.rs +++ b/crates/bindings/tests/ui.rs @@ -2,4 +2,5 @@ fn ui() { let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/*.rs"); + t.pass("tests/pass/*.rs"); } 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),