Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
92218be
feat: support option values in Rust index filters
Mr-Dust0 May 4, 2026
efb1507
fix(ts): compare option values in btree cache filters
Mr-Dust0 May 4, 2026
b789158
test(csharp): cover nullable btree index generation
Mr-Dust0 May 4, 2026
1e358f9
Merge branch 'master' into issue-4824-filterable-option
Mr-Dust0 Jun 3, 2026
b405f79
Merge remote-tracking branch 'origin/master' into issue-4824-filterab…
Mr-Dust0 Jun 5, 2026
a0d18c6
Merge branch 'master' into issue-4824-filterable-option
Mr-Dust0 Jun 8, 2026
aac5f7b
Merge branch 'master' into issue-4824-filterable-option
Mr-Dust0 Jun 8, 2026
e06a8e4
Merge branch 'master' into issue-4824-filterable-option
Mr-Dust0 Jun 8, 2026
692955f
Merge branch 'master' into issue-4824-filterable-option
Mr-Dust0 Jun 10, 2026
b5a7b27
Merge branch 'master' into issue-4824-filterable-option
Mr-Dust0 Jun 11, 2026
396ab9b
Merge branch 'master' into issue-4824-filterable-option
Mr-Dust0 Jun 12, 2026
399ab80
Merge branch 'master' into issue-4824-filterable-option
Mr-Dust0 Jun 14, 2026
6f3081d
Merge branch 'master' into issue-4824-filterable-option
Mr-Dust0 Jun 15, 2026
859d1fc
Merge branch 'master' into issue-4824-filterable-option
Mr-Dust0 Jun 16, 2026
b9d5771
Merge branch 'master' into issue-4824-filterable-option
Mr-Dust0 Jun 20, 2026
20cbf4d
Merge branch 'master' into issue-4824-filterable-option
Mr-Dust0 Jun 24, 2026
f234818
Make `Option`s filterable on clients, and add SDK tests.
gefjon Jun 26, 2026
e64e7c0
Remove debugging `println`s
gefjon Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions crates/bindings-csharp/Codegen.Tests/Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,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<uint?>(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()
{
Expand Down
21 changes: 21 additions & 0 deletions crates/bindings-typescript/src/lib/type_builders.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,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<typeof rowOptionIndex>;
// 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 = {
Expand Down
66 changes: 66 additions & 0 deletions crates/bindings-typescript/src/lib/type_builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,18 @@ interface Indexable<
): ColumnBuilder<Type, SpacetimeType, SetField<M, 'indexType', N>>;
}

type IndexableBuilder<Value extends TypeBuilder<any, any>> = Value &
Indexable<
InferTypeOfTypeBuilder<Value>,
InferSpacetimeTypeOfTypeBuilder<Value>
>;

type UniqueableBuilder<Value extends TypeBuilder<any, any>> = Value &
Uniqueable<
InferTypeOfTypeBuilder<Value>,
InferSpacetimeTypeOfTypeBuilder<Value>
>;

/**
* Interface for types that can be converted into a column builder with auto-increment metadata.
*
Expand Down Expand Up @@ -1376,6 +1388,36 @@ export class OptionBuilder<Value extends TypeBuilder<any, any>>
super(Option.getAlgebraicType(value.algebraicType));
this.value = value;
}
index(
this: OptionBuilder<IndexableBuilder<Value>>
): OptionColumnBuilder<
Value,
SetField<DefaultMetadata, 'indexType', 'btree'>
>;
index<N extends NonNullable<IndexTypes>>(
this: OptionBuilder<IndexableBuilder<Value>>,
algorithm: N
): OptionColumnBuilder<Value, SetField<DefaultMetadata, 'indexType', N>>;
index(
this: OptionBuilder<IndexableBuilder<Value>>,
algorithm: IndexTypes = 'btree'
): OptionColumnBuilder<
Value,
SetField<DefaultMetadata, 'indexType', IndexTypes>
> {
return new OptionColumnBuilder(
this,
set(defaultMetadata, { indexType: algorithm })
);
}
unique(
this: OptionBuilder<UniqueableBuilder<Value>>
): OptionColumnBuilder<Value, SetField<DefaultMetadata, 'isUnique', true>> {
return new OptionColumnBuilder(
this,
set(defaultMetadata, { isUnique: true })
);
}
default(
value: InferTypeOfTypeBuilder<Value> | undefined
): OptionColumnBuilder<
Expand Down Expand Up @@ -3161,6 +3203,30 @@ export class OptionColumnBuilder<
OptionAlgebraicType<InferSpacetimeTypeOfTypeBuilder<Value>>
>
{
index(
this: OptionColumnBuilder<IndexableBuilder<Value>, M>
): OptionColumnBuilder<Value, SetField<M, 'indexType', 'btree'>>;
index<N extends NonNullable<IndexTypes>>(
this: OptionColumnBuilder<IndexableBuilder<Value>, M>,
algorithm: N
): OptionColumnBuilder<Value, SetField<M, 'indexType', N>>;
index(
this: OptionColumnBuilder<IndexableBuilder<Value>, M>,
algorithm: IndexTypes = 'btree'
): OptionColumnBuilder<Value, SetField<M, 'indexType', IndexTypes>> {
return new OptionColumnBuilder(
this.typeBuilder,
set(this.columnMetadata, { indexType: algorithm })
);
}
unique(
this: OptionColumnBuilder<UniqueableBuilder<Value>, M>
): OptionColumnBuilder<Value, SetField<M, 'isUnique', true>> {
return new OptionColumnBuilder(
this.typeBuilder,
set(this.columnMetadata, { isUnique: true })
);
}
default(
value: InferTypeOfTypeBuilder<Value> | undefined
): OptionColumnBuilder<
Expand Down
30 changes: 23 additions & 7 deletions crates/bindings-typescript/src/sdk/table_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemoteModule>,
Expand Down Expand Up @@ -143,7 +159,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];
Expand All @@ -162,14 +178,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;
}
Expand All @@ -179,7 +195,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;
}
Expand All @@ -201,7 +217,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;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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<any, string>(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]);
});
});
37 changes: 37 additions & 0 deletions crates/bindings/tests/pass/option-index-filter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#[spacetimedb::table(accessor = option_index_args)]
struct OptionIndexArgs {
#[primary_key]
id: u64,
#[index(btree)]
option_u64: Option<u64>,
}

#[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<u64>,
}

#[spacetimedb::reducer]
fn option_index_filters_compile(ctx: &spacetimedb::ReducerContext) {
let some_u64 = Some(55u64);
let none_u64: Option<u64> = 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() {}
11 changes: 9 additions & 2 deletions crates/codegen/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,18 @@ pub(super) fn type_ref_name(module: &ModuleDef, typeref: AlgebraicTypeRef) -> St
pub(super) fn is_type_filterable(typespace: &TypespaceForGenerate, ty: &AlgebraicTypeUse) -> bool {
match ty {
AlgebraicTypeUse::Primitive(prim) => !matches!(prim, PrimitiveType::F32 | PrimitiveType::F64),
AlgebraicTypeUse::String | AlgebraicTypeUse::Identity | AlgebraicTypeUse::ConnectionId => true,
AlgebraicTypeUse::String
| AlgebraicTypeUse::Identity
| AlgebraicTypeUse::ConnectionId
| AlgebraicTypeUse::Uuid => true,
// Sum types with all unit variants:
AlgebraicTypeUse::Never => true,
AlgebraicTypeUse::Option(inner) => matches!(&**inner, AlgebraicTypeUse::Unit),
// At the top level, the only case where a ref can appear that should be filterable is a simple enum.
AlgebraicTypeUse::Ref(r) => typespace[r].is_plain_enum(),

// Options of filterable types:
AlgebraicTypeUse::Option(inner) => is_type_filterable(typespace, inner),

_ => false,
}
}
Expand Down
5 changes: 5 additions & 0 deletions crates/lib/src/filterable_value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ impl_filterable_value! {
// &[u8] => Vec<u8>,
}

impl<T: FilterableValue> Private for Option<T> {}
impl<T: FilterableValue> FilterableValue for Option<T> {
type Column = Option<T::Column>;
}

/// Marker trait for column types supported as procedural view primary keys.
#[doc(hidden)]
#[diagnostic::on_unimplemented(
Expand Down
Loading
Loading