Make Timestamp a FilterableValue (#4693)

# Description of Changes
Make Timestamp a FilterableValue in Rust, C#, and Typescript. I'm not
sure this is changing all the places because we have the server and the
client in those 3 languages.

# API and ABI breaking changes
It's an additive change.

# Expected complexity level and risk
3. There are some designs decisions, like comparing timestamp to
strings/numbers.

# Testing
Added unit tests for the 3 languages.
This commit is contained in:
Rafael Guerreiro
2026-04-28 15:06:44 -07:00
committed by GitHub
parent 193ddfd670
commit a8c8252a0b
9 changed files with 234 additions and 6 deletions
@@ -1,7 +1,9 @@
namespace SpacetimeDB;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Security.Cryptography;
using System.Threading;
using CsCheck;
using SpacetimeDB.BSATN;
using Xunit;
@@ -186,6 +188,27 @@ public static partial class BSATNRuntimeTests
Assert.Equal(+1, laterStamp.CompareTo(stamp));
}
[Fact]
public static void TimestampSqlLiteralUsesInvariantCulture()
{
var originalCulture = Thread.CurrentThread.CurrentCulture;
try
{
// Ensure the format is agnostic to the culture. Using ar-SA because it's different than Gregorian, which is used in UTC.
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("ar-SA");
Assert.Equal(
"'2025-01-22T21:53:13.990639Z'",
SqlLit.Timestamp(new Timestamp(1_737_582_793_990_639L)).ToString()
);
}
finally
{
Thread.CurrentThread.CurrentCulture = originalCulture;
}
}
[Fact]
public static void ConnectionIdComparableChecks()
{
@@ -58,6 +58,9 @@ public static class SqlLit
public static SqlLiteral<Uuid> Uuid(Uuid value) =>
new(SqlFormat.FormatHexLiteral(value.ToString()));
public static SqlLiteral<Timestamp> Timestamp(Timestamp value) =>
new(SqlFormat.FormatTimestampLiteral(value));
}
public interface IQuery<TRow>
@@ -744,6 +747,24 @@ public static class QueryBuilderExtensions
public static BoolExpr<TRow> Neq<TRow>(this Col<TRow, Uuid> col, Uuid value) =>
col.Neq(SqlLit.Uuid(value));
public static BoolExpr<TRow> Eq<TRow>(this Col<TRow, Timestamp> col, Timestamp value) =>
col.Eq(SqlLit.Timestamp(value));
public static BoolExpr<TRow> Neq<TRow>(this Col<TRow, Timestamp> col, Timestamp value) =>
col.Neq(SqlLit.Timestamp(value));
public static BoolExpr<TRow> Lt<TRow>(this Col<TRow, Timestamp> col, Timestamp value) =>
col.Lt(SqlLit.Timestamp(value));
public static BoolExpr<TRow> Lte<TRow>(this Col<TRow, Timestamp> col, Timestamp value) =>
col.Lte(SqlLit.Timestamp(value));
public static BoolExpr<TRow> Gt<TRow>(this Col<TRow, Timestamp> col, Timestamp value) =>
col.Gt(SqlLit.Timestamp(value));
public static BoolExpr<TRow> Gte<TRow>(this Col<TRow, Timestamp> col, Timestamp value) =>
col.Gte(SqlLit.Timestamp(value));
public static BoolExpr<TRow> Eq<TRow>(this IxCol<TRow, string> col, ReadOnlySpan<char> value) =>
col.Eq(SqlLit.String(value));
@@ -831,10 +852,18 @@ public static class QueryBuilderExtensions
public static BoolExpr<TRow> Neq<TRow>(this IxCol<TRow, Uuid> col, Uuid value) =>
col.Neq(SqlLit.Uuid(value));
public static BoolExpr<TRow> Eq<TRow>(this IxCol<TRow, Timestamp> col, Timestamp value) =>
col.Eq(SqlLit.Timestamp(value));
public static BoolExpr<TRow> Neq<TRow>(this IxCol<TRow, Timestamp> col, Timestamp value) =>
col.Neq(SqlLit.Timestamp(value));
}
internal static class SqlFormat
{
private const string TimestampFormat = "yyyy-MM-dd'T'HH:mm:ss.ffffff'Z'";
public static string QuoteIdent(string ident)
{
ident ??= string.Empty;
@@ -873,4 +902,12 @@ internal static class SqlFormat
return $"0x{s}";
}
public static string FormatTimestampLiteral(Timestamp timestamp) =>
FormatStringLiteral(
timestamp
.ToStd()
.ToUniversalTime()
.ToString(TimestampFormat, CultureInfo.InvariantCulture)
);
}
+12 -1
View File
@@ -1,7 +1,8 @@
import type { RowType, UntypedTableDef } from './table';
import { Timestamp } from './timestamp';
import { Uuid } from './uuid';
export type Value = string | number | boolean | Uuid;
export type Value = string | number | boolean | Uuid | Timestamp;
export type Expr<Column extends string> =
| { type: 'eq'; key: Column; value: Value }
@@ -77,6 +78,13 @@ export function evaluate<Column extends string>(
if (v instanceof Uuid && typeof expr.value === 'string') {
return v.toString() === expr.value;
}
if (v instanceof Timestamp) {
// Value of the Column and passed Value are both Timestamps so compare microseconds.
if (expr.value instanceof Timestamp) {
return v.microsSinceUnixEpoch === expr.value.microsSinceUnixEpoch;
}
}
}
return false;
}
@@ -103,6 +111,9 @@ function formatValue(v: Value): string {
if (v instanceof Uuid) {
return `'${v.toString()}'`;
}
if (v instanceof Timestamp) {
return `'${v.toISOString()}'`;
}
return '';
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { eq, evaluate, toString } from '../src/lib/filter';
import { ModuleContext, tablesToSchema } from '../src/lib/schema';
import { table } from '../src/lib/table';
import { Timestamp } from '../src/lib/timestamp';
import { t } from '../src/lib/type_builders';
const peopleTableDef = table(
{ name: 'people' },
{
createdAt: t.timestamp(),
id: t.u32(),
}
);
const schemaDef = tablesToSchema(new ModuleContext(), {
people: peopleTableDef,
});
describe('filter.ts timestamp support', () => {
it('evaluates timestamp equality by microseconds', () => {
const ts = Timestamp.fromDate(new Date('2024-01-01T00:00:00.123Z'));
expect(evaluate(eq('createdAt', ts), { createdAt: ts })).toBe(true);
expect(
evaluate(eq('createdAt', ts), {
createdAt: new Timestamp(ts.microsSinceUnixEpoch + 1n),
})
).toBe(false);
});
it('renders timestamp literals as ISO strings', () => {
const ts = Timestamp.fromDate(new Date('2024-01-01T00:00:00.123Z'));
expect(toString(schemaDef.tables.people, eq('createdAt', ts))).toBe(
`createdAt = '2024-01-01T00:00:00.123000Z'`
);
});
});
@@ -31,6 +31,7 @@ const personTable = table(
name: t.string(),
age: t.u32(),
active: t.bool(),
createdAt: t.timestamp(),
}
);
@@ -172,6 +173,26 @@ describe('TableScan.toSql', () => {
);
});
it('renders Timestamp literals as RFC3339 strings', () => {
const qb = makeQueryBuilder(schemaDef);
const timestamp = Timestamp.fromDate(new Date('2024-01-01T00:00:00.123Z'));
const sql = toSql(qb.person.where(row => row.createdAt.eq(timestamp)));
expect(sql).toBe(
`SELECT * FROM "person" WHERE "person"."createdAt" = '2024-01-01T00:00:00.123000Z'`
);
});
it('supports Timestamp comparisons in where predicates', () => {
const qb = makeQueryBuilder(schemaDef);
const timestamp = Timestamp.fromDate(new Date('2024-01-01T00:00:00.123Z'));
const sql = toSql(qb.person.where(row => row.createdAt.gt(timestamp)));
expect(sql).toBe(
`SELECT * FROM "person" WHERE "person"."createdAt" > '2024-01-01T00:00:00.123000Z'`
);
});
it('renders semijoin queries without additional filters', () => {
const qb = makeQueryBuilder(schemaDef);
const sql = toSql(
+2 -2
View File
@@ -203,10 +203,10 @@ error[E0277]: `&'a Alpha` cannot appear as an argument to an index filtering ope
--> tests/ui/tables.rs:32:33
|
32 | ctx.db.delta().compound_a().find(Alpha { beta: 0 });
| ^^^^ should be an integer type, `bool`, `String`, `&str`, `Identity`, `Uuid`, `ConnectionId`, `Hash` or a no-payload enum which derives `SpacetimeType`, not `&'a Alpha`
| ^^^^ should be an integer type, `bool`, `String`, `&str`, `Identity`, `Uuid`, `Timestamp`, `ConnectionId`, `Hash` or a no-payload enum which derives `SpacetimeType`, not `&'a Alpha`
|
= help: the trait `for<'a> FilterableValue` is not implemented for `&'a Alpha`
= note: The allowed set of types are limited to integers, bool, strings, `Identity`, `Uuid`, `ConnectionId`, `Hash` and no-payload enums which derive `SpacetimeType`,
= note: The allowed set of types are limited to integers, bool, strings, `Identity`, `Uuid`, `Timestamp`, `ConnectionId`, `Hash` and no-payload enums which derive `SpacetimeType`,
= help: the following other types implement trait `FilterableValue`:
&ConnectionId
&FunctionVisibility
+5 -3
View File
@@ -1,4 +1,4 @@
use crate::{ConnectionId, Identity, Uuid};
use crate::{ConnectionId, Identity, Timestamp, Uuid};
use core::ops;
use spacetimedb_sats::bsatn;
use spacetimedb_sats::{hash::Hash, i256, u256, Serialize};
@@ -17,6 +17,7 @@ use spacetimedb_sats::{hash::Hash, i256, u256, Serialize};
/// - [`String`], which is also filterable with `&str`.
/// - [`Identity`].
/// - [`Uuid`].
/// - [`Timestamp`].
/// - [`ConnectionId`].
/// - [`Hash`](struct@Hash).
/// - No-payload enums annotated with `#[derive(SpacetimeType)]`.
@@ -48,8 +49,8 @@ use spacetimedb_sats::{hash::Hash, i256, u256, Serialize};
// E.g. `&str: FilterableValue<Column = String>` is desirable.
#[diagnostic::on_unimplemented(
message = "`{Self}` cannot appear as an argument to an index filtering operation",
label = "should be an integer type, `bool`, `String`, `&str`, `Identity`, `Uuid`, `ConnectionId`, `Hash` or a no-payload enum which derives `SpacetimeType`, not `{Self}`",
note = "The allowed set of types are limited to integers, bool, strings, `Identity`, `Uuid`, `ConnectionId`, `Hash` and no-payload enums which derive `SpacetimeType`,"
label = "should be an integer type, `bool`, `String`, `&str`, `Identity`, `Uuid`, `Timestamp`, `ConnectionId`, `Hash` or a no-payload enum which derives `SpacetimeType`, not `{Self}`",
note = "The allowed set of types are limited to integers, bool, strings, `Identity`, `Uuid`, `Timestamp`, `ConnectionId`, `Hash` and no-payload enums which derive `SpacetimeType`,"
)]
pub trait FilterableValue: Serialize + Private {
type Column;
@@ -106,6 +107,7 @@ impl_filterable_value! {
Identity: Copy,
Uuid: Copy,
Timestamp: Copy,
ConnectionId: Copy,
Hash: Copy,
+5
View File
@@ -430,6 +430,8 @@ pub fn query_private(ctx: &ReducerContext) {
/// and therefore that all of the different accesses listed here are well-typed.
// TODO(testing): Add tests (in smoketests?) for index arg combos which are expected not to compile.
fn test_btree_index_args(ctx: &ReducerContext) {
fn assert_filterable<T: spacetimedb::FilterableValue>() {}
// Single-column string index on `test_e.name`:
// Tests that we can pass `&String` or `&str`, but not `str`.
let string = "String".to_string();
@@ -498,6 +500,9 @@ fn test_btree_index_args(ctx: &ReducerContext) {
let _ = ctx.db.points().multi_column_index().filter((&0i64, &1i64..&3i64));
// ctx.db.points().multi_column_index().filter((0i64..3i64, 1i64)); // SHOULD FAIL
// Timestamp filterability coverage.
assert_filterable::<Timestamp>();
assert_filterable::<&Timestamp>();
}
#[spacetimedb::reducer]
+90
View File
@@ -1,6 +1,8 @@
namespace SpacetimeDB.Tests;
using System;
using System.Globalization;
using System.Threading;
using Xunit;
public sealed class QueryBuilderTests
@@ -17,6 +19,7 @@ public sealed class QueryBuilderTests
public Col<Row, string> Weird { get; }
public Col<Row, int> Age { get; }
public Col<Row, bool> IsAdmin { get; }
public Col<Row, Timestamp> CreatedAt { get; }
public RowCols(string tableName)
{
@@ -24,16 +27,19 @@ public sealed class QueryBuilderTests
Weird = new Col<Row, string>(tableName, "we\"ird");
Age = new Col<Row, int>(tableName, "Age");
IsAdmin = new Col<Row, bool>(tableName, "IsAdmin");
CreatedAt = new Col<Row, Timestamp>(tableName, "CreatedAt");
}
}
private sealed class RowIxCols
{
public IxCol<Row, string> Name { get; }
public IxCol<Row, Timestamp> CreatedAt { get; }
public RowIxCols(string tableName)
{
Name = new IxCol<Row, string>(tableName, "Name");
CreatedAt = new IxCol<Row, Timestamp>(tableName, "CreatedAt");
}
}
@@ -206,6 +212,48 @@ public sealed class QueryBuilderTests
);
}
[Fact]
public void FormatLiteral_Timestamp_UsesQuotedIsoString()
{
var table = MakeTable("T");
var timestamp = new Timestamp(1_737_582_793_990_639L);
const string expected = "2025-01-22T21:53:13.990639Z";
Assert.Equal(
$"SELECT * FROM \"T\" WHERE (\"T\".\"CreatedAt\" = '{expected}')",
table.Where(c => c.CreatedAt.Eq(timestamp)).ToSql()
);
}
[Fact]
public void Where_Timestamp_ComparisonOperators_FormatCorrectly()
{
var table = MakeTable("T");
var timestamp = new Timestamp(1_737_582_793_990_639L);
const string expected = "2025-01-22T21:53:13.990639Z";
Assert.Equal(
$"SELECT * FROM \"T\" WHERE (\"T\".\"CreatedAt\" <> '{expected}')",
table.Where(c => c.CreatedAt.Neq(timestamp)).ToSql()
);
Assert.Equal(
$"SELECT * FROM \"T\" WHERE (\"T\".\"CreatedAt\" < '{expected}')",
table.Where(c => c.CreatedAt.Lt(timestamp)).ToSql()
);
Assert.Equal(
$"SELECT * FROM \"T\" WHERE (\"T\".\"CreatedAt\" <= '{expected}')",
table.Where(c => c.CreatedAt.Lte(timestamp)).ToSql()
);
Assert.Equal(
$"SELECT * FROM \"T\" WHERE (\"T\".\"CreatedAt\" > '{expected}')",
table.Where(c => c.CreatedAt.Gt(timestamp)).ToSql()
);
Assert.Equal(
$"SELECT * FROM \"T\" WHERE (\"T\".\"CreatedAt\" >= '{expected}')",
table.Where(c => c.CreatedAt.Gte(timestamp)).ToSql()
);
}
[Fact]
public void IxCol_EqNeq_FormatsCorrectly()
{
@@ -221,6 +269,48 @@ public sealed class QueryBuilderTests
);
}
[Fact]
public void IxCol_Timestamp_EqNeq_FormatsCorrectly()
{
var table = MakeTable("T");
var timestamp = new Timestamp(1_737_582_793_990_639L);
const string expected = "2025-01-22T21:53:13.990639Z";
Assert.Equal(
$"SELECT * FROM \"T\" WHERE (\"T\".\"CreatedAt\" = '{expected}')",
table.Where((_, ix) => ix.CreatedAt.Eq(timestamp)).ToSql()
);
Assert.Equal(
$"SELECT * FROM \"T\" WHERE (\"T\".\"CreatedAt\" <> '{expected}')",
table.Where((_, ix) => ix.CreatedAt.Neq(timestamp)).ToSql()
);
}
[Fact]
public void FormatLiteral_Timestamp_UsesInvariantCulture()
{
var table = MakeTable("T");
var timestamp = new Timestamp(1_737_582_793_990_639L);
const string expectedSql =
"SELECT * FROM \"T\" WHERE (\"T\".\"CreatedAt\" = '2025-01-22T21:53:13.990639Z')";
var originalCulture = Thread.CurrentThread.CurrentCulture;
try
{
// Ensure the format is agnostic to the culture. Using ar-SA because it's different than Gregorian, which is used in UTC.
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("ar-SA");
Assert.Equal(
expectedSql,
table.Where(c => c.CreatedAt.Eq(timestamp)).ToSql()
);
}
finally
{
Thread.CurrentThread.CurrentCulture = originalCulture;
}
}
[Fact]
public void LeftSemijoin_Build_FormatsCorrectly()
{