Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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<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: 15 additions & 6 deletions crates/bindings-typescript/src/sdk/table_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemoteModule>,
Expand Down Expand Up @@ -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];
Expand All @@ -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;
}
Expand All @@ -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;
}
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,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<any, string>(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]);
});
});
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>;
}

pub enum TermBound<T> {
Single(ops::Bound<T>),
Range(ops::Bound<T>, ops::Bound<T>),
Expand Down
17 changes: 17 additions & 0 deletions modules/module-test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
}

#[derive(SpacetimeType)]
pub struct Baz {
pub field: String,
Expand Down Expand Up @@ -445,6 +453,15 @@ fn test_btree_index_args(ctx: &ReducerContext) {

// ctx.db.test_e().name().delete(string); // SHOULD FAIL

// Single-column Option<u64> 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<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));

// Multi-column i64 index on `points.x, points.y`:
// Tests that we can pass various ranges
// and various combinations of borrowed/owned `Copy` values.
Expand Down