mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-05-06 07:26:43 -04:00
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:
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user