@tanstack/db
Version:
A reactive client store for building super fast apps on sync
577 lines (497 loc) • 13.7 kB
text/typescript
import { isRefProxy, toExpression } from './builder/ref-proxy.js'
import { getQueryIR } from './builder/index.js'
import type {
Aggregate,
BasicExpression,
ConditionalSelect,
From,
Having,
IncludesSubquery,
JoinClause,
OrderByClause,
QueryIR,
Select,
Where,
} from './ir.js'
import type { InitialQueryBuilder, QueryBuilder } from './builder/index.js'
type StableIdentityValue =
| null
| boolean
| number
| string
| Array<StableIdentityValue>
| { [key: string]: StableIdentityValue }
export class UnhashableQueryIRError extends Error {
constructor(
public readonly path: string,
public readonly reason: string,
) {
super(`Query IR is not stably hashable at ${path}: ${reason}`)
this.name = `UnhashableQueryIRError`
}
}
export function getStableQueryIRHash(query: QueryIR): string {
return JSON.stringify(canonicalizeQueryIR(query))
}
export function getStableQueryBuilderHash(
query: InitialQueryBuilder | QueryBuilder<any>,
): string {
return getStableQueryIRHash(getQueryIR(query))
}
export function getStableValueHash(value: unknown, path = `value`): string {
return JSON.stringify(canonicalizeRuntimeValue(value, path, new WeakSet()))
}
export function canonicalizeQueryIR(query: QueryIR): StableIdentityValue {
return canonicalizeQuery(query, `query`, new WeakSet<object>())
}
function canonicalizeQuery(
query: QueryIR,
path: string,
seen: WeakSet<object>,
): StableIdentityValue {
if (query.fnSelect) {
throw new UnhashableQueryIRError(`${path}.fnSelect`, `function select`)
}
if (query.fnWhere?.length) {
throw new UnhashableQueryIRError(`${path}.fnWhere`, `function where`)
}
if (query.fnHaving?.length) {
throw new UnhashableQueryIRError(`${path}.fnHaving`, `function having`)
}
const result: Record<string, StableIdentityValue> = {
type: `query`,
from: canonicalizeSource(query.from, `${path}.from`, seen),
}
if (query.select) {
result.select = canonicalizeSelect(query.select, `${path}.select`, seen)
}
if (query.join) {
result.join = query.join.map((join, index) =>
canonicalizeJoin(join, `${path}.join[${index}]`, seen),
)
}
if (query.where) {
result.where = query.where.map((where, index) =>
canonicalizeWhere(where, `${path}.where[${index}]`, seen),
)
}
if (query.groupBy) {
result.groupBy = query.groupBy.map((expression, index) =>
canonicalizeExpression(expression, `${path}.groupBy[${index}]`, seen),
)
}
if (query.having) {
result.having = query.having.map((having, index) =>
canonicalizeWhere(having, `${path}.having[${index}]`, seen),
)
}
if (query.orderBy) {
result.orderBy = query.orderBy.map((orderBy, index) =>
canonicalizeOrderBy(orderBy, `${path}.orderBy[${index}]`, seen),
)
}
if (query.limit !== undefined) {
result.limit = canonicalizeRuntimeValue(query.limit, `${path}.limit`, seen)
}
if (query.offset !== undefined) {
result.offset = canonicalizeRuntimeValue(
query.offset,
`${path}.offset`,
seen,
)
}
if (query.distinct) {
result.distinct = true
}
if (query.singleResult) {
result.singleResult = true
}
return result
}
function canonicalizeJoin(
join: JoinClause,
path: string,
seen: WeakSet<object>,
): StableIdentityValue {
return {
type: join.type,
from: canonicalizeSource(join.from, `${path}.from`, seen),
left: canonicalizeExpression(join.left, `${path}.left`, seen),
right: canonicalizeExpression(join.right, `${path}.right`, seen),
}
}
function canonicalizeSource(
source: From,
path: string,
seen: WeakSet<object>,
): StableIdentityValue {
if (source.type === `collectionRef`) {
return {
type: `collectionRef`,
alias: source.alias,
collectionId: canonicalizeRuntimeValue(
source.collection.id,
`${path}.collection.id`,
seen,
),
}
}
if (source.type === `unionFrom`) {
return {
type: `unionFrom`,
sources: source.sources.map((unionSource, index) =>
canonicalizeSource(unionSource, `${path}.sources[${index}]`, seen),
),
}
}
if (source.type === `unionAll`) {
return {
type: `unionAll`,
queries: source.queries.map((query, index) =>
canonicalizeQuery(query, `${path}.queries[${index}]`, seen),
),
}
}
return {
type: `queryRef`,
alias: source.alias,
query: canonicalizeQuery(source.query, `${path}.query`, seen),
}
}
function canonicalizeSelect(
select: Select,
path: string,
seen: WeakSet<object>,
): StableIdentityValue {
return {
type: `select`,
fields: Object.keys(select)
.sort()
.map((key) => [
key,
canonicalizeSelectValue(select[key]!, `${path}.${key}`, seen),
]),
}
}
function canonicalizeSelectValue(
value: unknown,
path: string,
seen: WeakSet<object>,
): StableIdentityValue {
if (isRefProxy(value)) {
return canonicalizeExpression(toExpression(value), path, seen)
}
if (isExpression(value)) {
return canonicalizeExpression(value, path, seen)
}
if (isPlainObject(value)) {
return canonicalizeSelect(value as Select, path, seen)
}
return canonicalizeRuntimeValue(value, path, seen)
}
function canonicalizeWhere(
where: Where | Having,
path: string,
seen: WeakSet<object>,
): StableIdentityValue {
if (isWhereObject(where)) {
const result: Record<string, StableIdentityValue> = {
type: `where`,
expression: canonicalizeExpression(
where.expression,
`${path}.expression`,
seen,
),
}
if (where.residual === true) {
result.residual = true
}
return result
}
return canonicalizeExpression(where, path, seen)
}
function canonicalizeOrderBy(
orderBy: OrderByClause,
path: string,
seen: WeakSet<object>,
): StableIdentityValue {
return {
expression: canonicalizeExpression(
orderBy.expression,
`${path}.expression`,
seen,
),
compareOptions: canonicalizeRuntimeValue(
orderBy.compareOptions,
`${path}.compareOptions`,
seen,
),
}
}
function canonicalizeExpression(
expression:
| BasicExpression
| Aggregate
| IncludesSubquery
| ConditionalSelect,
path: string,
seen: WeakSet<object>,
): StableIdentityValue {
if (expression.type === `ref`) {
return {
type: `ref`,
path: expression.path.map((segment, index) =>
canonicalizeRuntimeValue(segment, `${path}.path[${index}]`, seen),
),
}
}
if (expression.type === `val`) {
return {
type: `val`,
value: canonicalizeRuntimeValue(expression.value, `${path}.value`, seen),
}
}
if (expression.type === `func`) {
return {
type: `func`,
name: expression.name,
args: expression.args.map((arg, index) =>
canonicalizeExpression(arg, `${path}.args[${index}]`, seen),
),
}
}
if (expression.type === `agg`) {
return {
type: `agg`,
name: expression.name,
args: expression.args.map((arg, index) =>
canonicalizeExpression(arg, `${path}.args[${index}]`, seen),
),
}
}
if (expression.type === `conditionalSelect`) {
const result: Record<string, StableIdentityValue> = {
type: `conditionalSelect`,
branches: expression.branches.map((branch, index) => ({
condition: canonicalizeExpression(
branch.condition,
`${path}.branches[${index}].condition`,
seen,
),
value: canonicalizeSelectValue(
branch.value,
`${path}.branches[${index}].value`,
seen,
),
})),
}
if (expression.defaultValue !== undefined) {
result.defaultValue = canonicalizeSelectValue(
expression.defaultValue,
`${path}.defaultValue`,
seen,
)
}
return result
}
const result: Record<string, StableIdentityValue> = {
type: `includesSubquery`,
query: canonicalizeQuery(expression.query, `${path}.query`, seen),
correlationField: canonicalizeExpression(
expression.correlationField,
`${path}.correlationField`,
seen,
),
childCorrelationField: canonicalizeExpression(
expression.childCorrelationField,
`${path}.childCorrelationField`,
seen,
),
fieldName: expression.fieldName,
materialization: expression.materialization,
}
if (expression.parentFilters) {
result.parentFilters = expression.parentFilters.map((where, index) =>
canonicalizeWhere(where, `${path}.parentFilters[${index}]`, seen),
)
}
if (expression.parentProjection) {
result.parentProjection = expression.parentProjection.map(
(projection, index) =>
canonicalizeExpression(
projection,
`${path}.parentProjection[${index}]`,
seen,
),
)
}
if (expression.scalarField !== undefined) {
result.scalarField = expression.scalarField
}
return result
}
function canonicalizeRuntimeValue(
value: unknown,
path: string,
seen: WeakSet<object>,
): StableIdentityValue {
if (value === null) return [`null`]
if (typeof value === `string`) {
return [`string`, value]
}
if (typeof value === `boolean`) {
return [`boolean`, value]
}
if (typeof value === `number`) {
if (Number.isNaN(value)) {
return [`number`, `NaN`]
}
if (value === Infinity) {
return [`number`, `Infinity`]
}
if (value === -Infinity) {
return [`number`, `-Infinity`]
}
if (Object.is(value, -0)) {
return [`number`, `-0`]
}
return [`number`, value]
}
if (typeof value === `undefined`) {
return [`undefined`]
}
if (typeof value === `bigint`) {
return [`bigint`, value.toString()]
}
if (typeof value === `function`) {
throw new UnhashableQueryIRError(path, `function value`)
}
if (typeof value === `symbol`) {
throw new UnhashableQueryIRError(path, `symbol value`)
}
if (isRefProxy(value)) {
return canonicalizeExpression(toExpression(value), path, seen)
}
if (Array.isArray(value)) {
return withCircularGuard(value, path, seen, () => [
`array`,
value.map((item, index) =>
canonicalizeRuntimeValue(item, `${path}[${index}]`, seen),
),
])
}
if (value instanceof Date) {
const timestamp = value.getTime()
if (Number.isNaN(timestamp)) {
throw new UnhashableQueryIRError(path, `invalid Date`)
}
return [`Date`, value.toISOString()]
}
if (value instanceof ArrayBuffer) {
return [`binary`, `ArrayBuffer`, Array.from(new Uint8Array(value))]
}
if (ArrayBuffer.isView(value)) {
return [
`binary`,
value.constructor.name,
Array.from(
new Uint8Array(value.buffer, value.byteOffset, value.byteLength),
),
]
}
if (value instanceof Map) {
return withCircularGuard(value, path, seen, () => {
const entries = Array.from(
value.entries(),
([key, entryValue], index) => [
canonicalizeRuntimeValue(key, `${path}.key[${index}]`, seen),
canonicalizeRuntimeValue(entryValue, `${path}.value[${index}]`, seen),
],
)
entries.sort(compareStableIdentityValues)
return [`Map`, entries]
})
}
if (value instanceof Set) {
return withCircularGuard(value, path, seen, () => {
const entries = Array.from(value, (entry, index) =>
canonicalizeRuntimeValue(entry, `${path}[${index}]`, seen),
)
entries.sort(compareStableIdentityValues)
return [`Set`, entries]
})
}
if (isPlainObject(value)) {
return canonicalizeObject(value, path, seen)
}
throw new UnhashableQueryIRError(path, `non-plain object value`)
}
function compareStableIdentityValues(
left: StableIdentityValue,
right: StableIdentityValue,
): number {
const serializedLeft = JSON.stringify(left)
const serializedRight = JSON.stringify(right)
return serializedLeft < serializedRight
? -1
: serializedLeft > serializedRight
? 1
: 0
}
function canonicalizeObject(
value: Record<string, unknown>,
path: string,
seen: WeakSet<object>,
): StableIdentityValue {
return withCircularGuard(value, path, seen, () => [
`object`,
Object.keys(value)
.sort()
.map((key) => [
key,
canonicalizeRuntimeValue(value[key], `${path}.${key}`, seen),
]),
])
}
function withCircularGuard<T>(
value: object,
path: string,
seen: WeakSet<object>,
callback: () => T,
): T {
if (seen.has(value)) {
throw new UnhashableQueryIRError(path, `circular value`)
}
seen.add(value)
try {
return callback()
} finally {
seen.delete(value)
}
}
function isWhereObject(
where: Where | Having,
): where is { expression: BasicExpression<boolean>; residual?: boolean } {
return `expression` in where
}
function isExpression(
value: unknown,
): value is BasicExpression | Aggregate | IncludesSubquery {
if (value === null || typeof value !== `object`) {
return false
}
const expressionType = (value as { type?: unknown }).type
return (
expressionType === `agg` ||
expressionType === `conditionalSelect` ||
expressionType === `func` ||
expressionType === `ref` ||
expressionType === `val` ||
expressionType === `includesSubquery`
)
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== `object`) return false
const prototype = Object.getPrototypeOf(value)
return prototype === Object.prototype || prototype === null
}