@tanstack/db
Version:
A reactive client store for building super fast apps on sync
1,159 lines (1,033 loc) • 28.6 kB
text/typescript
import { normalizeValue } from '../utils/comparison.js'
import { isRefProxy, toExpression } from './builder/ref-proxy.js'
import { getQueryIR } from './builder/index.js'
import { getRuntimeReferenceIdentity } from './runtime-reference-identity.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'
import type { LoadSubsetOptions } from '../types.js'
type StableIdentityValue =
| null
| boolean
| number
| string
| Array<StableIdentityValue>
| { [key: string]: StableIdentityValue }
type ValueIdentityContext =
| `exact-output`
| `equality-operand`
| `ordering-operand`
type AliasScope = {
bindings: ReadonlyMap<string, number>
hasUnqualifiedOutput: boolean
parent: AliasScope | undefined
}
declare const queryIdentityBrand: unique symbol
declare const demandKeyBrand: unique symbol
/** Semantic identity for a query plan, independent of its runtime owners. */
export type QueryIdentity = string & {
readonly [queryIdentityBrand]: true
}
/** Exact identity for one loadSubset demand, including its requested window. */
export type DemandKey = string & {
readonly [demandKeyBrand]: true
}
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 getQueryIdentity(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()))
}
/**
* Returns the semantic identity of a structured query.
*
* Logical conjunctions and disjunctions are associative, commutative, and
* idempotent. Equality operands are commutative, while reversed inequalities
* are normalized by inverting their operator. Order-sensitive clauses and
* function arguments retain their original order.
*/
export function getQueryIdentity(query: QueryIR): QueryIdentity {
return JSON.stringify(canonicalizeQueryIR(query)) as QueryIdentity
}
/** Returns the semantic identity of one structured expression. */
export function getStableExpressionHash(expression: BasicExpression): string {
return JSON.stringify(
canonicalizeExpression(
expression,
`expression`,
new WeakSet(),
`exact-output`,
),
)
}
/**
* Returns the exact semantic identity of a loadSubset request.
*
* Abort signals and subscriptions are owners of a request, not part of the
* requested data, and therefore do not affect the key. A demand generation
* scopes one asynchronous attempt rather than the data it requests. Code that
* rejects stale work compares this key alongside its generation; query-db uses
* the key alone so equivalent data demands can reuse one cache entry across
* generations.
*/
export function getLoadSubsetDemandKey(
options: LoadSubsetOptions,
): DemandKey | undefined {
if (
options.where === undefined &&
!options.orderBy?.length &&
options.limit === undefined &&
(options.offset === undefined || options.offset === 0) &&
options.cursor === undefined
) {
// Query-db uses its base query key for the one unconstrained demand. An
// owner-only option must not create another cache entry for the same data.
return undefined
}
const seen = new WeakSet<object>()
const result: Record<string, StableIdentityValue> = {
type: `loadSubsetDemand`,
query: canonicalizeLoadSubsetQuery(options, `loadSubset`, seen),
}
if (options.limit !== undefined) {
result.limit = canonicalizeRuntimeValue(
options.limit,
`loadSubset.limit`,
seen,
)
}
if (options.offset !== undefined && options.offset !== 0) {
result.offset = canonicalizeRuntimeValue(
options.offset,
`loadSubset.offset`,
seen,
)
}
if (options.cursor !== undefined) {
const cursor: Record<string, StableIdentityValue> = {
whereFrom: canonicalizeExpression(
options.cursor.whereFrom,
`loadSubset.cursor.whereFrom`,
seen,
`exact-output`,
),
whereCurrent: canonicalizeExpression(
options.cursor.whereCurrent,
`loadSubset.cursor.whereCurrent`,
seen,
`exact-output`,
),
}
if (options.cursor.lastKey !== undefined) {
cursor.lastKey = canonicalizeRuntimeValue(
options.cursor.lastKey,
`loadSubset.cursor.lastKey`,
seen,
)
}
result.cursor = cursor
}
return JSON.stringify(result) as DemandKey
}
export function canonicalizeQueryIR(query: QueryIR): StableIdentityValue {
return canonicalizeQuery(query, `query`, new WeakSet<object>())
}
function createAliasScope(
query: QueryIR,
parent: AliasScope | undefined,
): AliasScope {
const bindings = new Map<string, number>()
const bindSource = (source: From): void => {
if (source.type === `unionFrom`) {
source.sources.forEach(bindSource)
return
}
if (source.type === `unionAll`) return
if (!bindings.has(source.alias)) {
bindings.set(source.alias, bindings.size)
}
}
bindSource(query.from)
query.join?.forEach(({ from }) => bindSource(from))
return {
bindings,
hasUnqualifiedOutput: query.from.type === `unionAll`,
parent,
}
}
function resolveAliasBinding(
scope: AliasScope | undefined,
alias: string,
): readonly [number, number] | undefined {
let current = scope
let parentDistance = 0
while (current) {
const binding = current.bindings.get(alias)
if (binding !== undefined) return [parentDistance, binding]
// A result-level union has no source alias. Every downstream ref starts at
// an output field, including nested paths such as profile.id, so it must
// not fall through and bind that field name to an enclosing query alias.
if (current.hasUnqualifiedOutput) return undefined
current = current.parent
parentDistance++
}
return undefined
}
function canonicalizeQuery(
query: QueryIR,
path: string,
seen: WeakSet<object>,
parentScope?: AliasScope,
): StableIdentityValue {
return canonicalizeQueryInScope(
query,
path,
seen,
createAliasScope(query, parentScope),
)
}
function canonicalizeQueryInScope(
query: QueryIR,
path: string,
seen: WeakSet<object>,
scope: AliasScope,
): 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, scope),
}
if (query.select) {
result.select = canonicalizeSelect(
query.select,
`${path}.select`,
seen,
scope,
)
}
if (
!query.select &&
(query.from.type === `unionFrom` ||
query.join !== undefined ||
query.groupBy !== undefined)
) {
// Without an explicit projection, these query shapes return a namespaced
// row. Its alias keys are public output and therefore part of identity.
result.implicitOutput = {
type: `namespaced`,
aliases: Array.from(scope.bindings.keys()),
}
}
if (query.join) {
result.join = query.join.map((join, index) =>
canonicalizeJoin(join, `${path}.join[${index}]`, seen, scope),
)
}
if (query.where) {
result.where = canonicalizeImplicitConjunction(
query.where,
`${path}.where`,
seen,
scope,
)
}
if (query.groupBy) {
result.groupBy = query.groupBy.map((expression, index) =>
canonicalizeExpression(
expression,
`${path}.groupBy[${index}]`,
seen,
`exact-output`,
scope,
),
)
}
if (query.having) {
result.having = canonicalizeImplicitConjunction(
query.having,
`${path}.having`,
seen,
scope,
)
}
if (query.orderBy) {
result.orderBy = query.orderBy.map((orderBy, index) =>
canonicalizeOrderBy(
orderBy,
`${path}.orderBy[${index}]`,
seen,
`ordering-operand`,
scope,
),
)
}
if (query.limit !== undefined) {
result.limit = canonicalizeRuntimeValue(query.limit, `${path}.limit`, seen)
}
if (query.offset !== undefined && query.offset !== 0) {
result.offset = canonicalizeRuntimeValue(
query.offset,
`${path}.offset`,
seen,
)
}
if (query.distinct) {
result.distinct = true
}
if (query.singleResult) {
result.singleResult = true
}
return result
}
function canonicalizeImplicitConjunction(
clauses: ReadonlyArray<Where | Having>,
path: string,
seen: WeakSet<object>,
scope: AliasScope,
): Array<StableIdentityValue> {
const canonical = clauses.map((clause, index) =>
canonicalizeWhere(clause, `${path}[${index}]`, seen, scope),
)
canonical.sort(compareStableIdentityValues)
return canonical.filter(
(clause, index) =>
index === 0 ||
compareStableIdentityValues(clause, canonical[index - 1]!) !== 0,
)
}
function canonicalizeLoadSubsetQuery(
options: LoadSubsetOptions,
path: string,
seen: WeakSet<object>,
): StableIdentityValue {
const result: Record<string, StableIdentityValue> = {
type: `loadSubsetQuery`,
}
if (options.where !== undefined) {
result.where = canonicalizeExpression(
options.where,
`${path}.where`,
seen,
`exact-output`,
)
}
if (options.orderBy?.length) {
result.orderBy = options.orderBy.map((orderBy, index) =>
canonicalizeOrderBy(
orderBy,
`${path}.orderBy[${index}]`,
seen,
`ordering-operand`,
),
)
}
return result
}
function canonicalizeJoin(
join: JoinClause,
path: string,
seen: WeakSet<object>,
scope: AliasScope,
): StableIdentityValue {
return {
type: join.type,
from: canonicalizeSource(join.from, `${path}.from`, seen, scope),
left: canonicalizeExpression(
join.left,
`${path}.left`,
seen,
`equality-operand`,
scope,
),
right: canonicalizeExpression(
join.right,
`${path}.right`,
seen,
`equality-operand`,
scope,
),
}
}
function canonicalizeSource(
source: From,
path: string,
seen: WeakSet<object>,
scope: AliasScope,
): StableIdentityValue {
if (source.type === `collectionRef`) {
return {
type: `collectionRef`,
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,
scope,
),
),
}
}
if (source.type === `unionAll`) {
return {
type: `unionAll`,
queries: source.queries.map((query, index) =>
// Branches are peers that may capture the union query's outer scope;
// they are not children of the union result row itself.
canonicalizeQuery(
query,
`${path}.queries[${index}]`,
seen,
scope.parent,
),
),
}
}
return {
type: `queryRef`,
query: canonicalizeQuery(source.query, `${path}.query`, seen, scope),
}
}
function canonicalizeSelect(
select: Select,
path: string,
seen: WeakSet<object>,
scope?: AliasScope,
): StableIdentityValue {
return {
type: `select`,
fields: Object.keys(select)
.sort()
.map((key) => [
key,
canonicalizeSelectValue(select[key]!, `${path}.${key}`, seen, scope),
]),
}
}
function canonicalizeSelectValue(
value: unknown,
path: string,
seen: WeakSet<object>,
scope?: AliasScope,
): StableIdentityValue {
if (isRefProxy(value)) {
return canonicalizeExpression(
toExpression(value),
path,
seen,
`exact-output`,
scope,
)
}
if (isExpression(value)) {
return canonicalizeExpression(value, path, seen, `exact-output`, scope)
}
if (isPlainObject(value)) {
return canonicalizeSelect(value as Select, path, seen, scope)
}
return canonicalizeExactOutputRuntimeValue(value, path, seen)
}
function canonicalizeWhere(
where: Where | Having,
path: string,
seen: WeakSet<object>,
scope?: AliasScope,
): StableIdentityValue {
if (isWhereObject(where)) {
const result: Record<string, StableIdentityValue> = {
type: `where`,
expression: canonicalizeExpression(
where.expression,
`${path}.expression`,
seen,
`exact-output`,
scope,
),
}
if (where.residual === true) {
result.residual = true
}
return result
}
return canonicalizeExpression(where, path, seen, `exact-output`, scope)
}
function canonicalizeOrderBy(
orderBy: OrderByClause,
path: string,
seen: WeakSet<object>,
valueContext: ValueIdentityContext = `exact-output`,
scope?: AliasScope,
): StableIdentityValue {
return {
expression: canonicalizeExpression(
orderBy.expression,
`${path}.expression`,
seen,
valueContext,
scope,
),
compareOptions: canonicalizeRuntimeValue(
orderBy.compareOptions,
`${path}.compareOptions`,
seen,
),
}
}
function canonicalizeExpression(
expression:
| BasicExpression
| Aggregate
| IncludesSubquery
| ConditionalSelect,
path: string,
seen: WeakSet<object>,
valueContext: ValueIdentityContext = `exact-output`,
scope?: AliasScope,
): StableIdentityValue {
if (expression.type === `ref`) {
const binding = resolveAliasBinding(scope, expression.path[0] ?? ``)
return {
type: `ref`,
path:
binding === undefined
? expression.path.map((segment, index) =>
canonicalizeRuntimeValue(segment, `${path}.path[${index}]`, seen),
)
: [
[`binding`, ...binding],
...expression.path
.slice(1)
.map((segment, index) =>
canonicalizeRuntimeValue(
segment,
`${path}.path[${index + 1}]`,
seen,
),
),
],
}
}
if (expression.type === `val`) {
return {
type: `val`,
value:
valueContext === `equality-operand`
? canonicalizeEqualityRuntimeValue(
expression.value,
`${path}.value`,
seen,
scope,
)
: valueContext === `ordering-operand`
? canonicalizeOrderingRuntimeValue(
expression.value,
`${path}.value`,
seen,
)
: canonicalizeExactOutputRuntimeValue(
expression.value,
`${path}.value`,
seen,
),
}
}
if (expression.type === `func`) {
if (
expression.name === `in` &&
expression.args.length === 2 &&
expression.args[1]?.type === `val` &&
Array.isArray(expression.args[1].value)
) {
const candidates = expression.args[1].value.map((value, index) =>
canonicalizeEqualityRuntimeValue(
value,
`${path}.args[1].value[${index}]`,
seen,
scope,
),
)
return canonicalizeFunction(expression.name, [
canonicalizeExpression(
expression.args[0]!,
`${path}.args[0]`,
seen,
`equality-operand`,
scope,
),
{
type: `val`,
// IN tests membership. Candidate order and duplicates do not change
// its result, but each candidate keeps its own equality semantics.
value: [`set`, sortUniqueStableIdentityValues(candidates)],
},
])
}
const operandContext: ValueIdentityContext =
expression.name === `eq`
? `equality-operand`
: expression.name === `gt` ||
expression.name === `gte` ||
expression.name === `lt` ||
expression.name === `lte`
? `ordering-operand`
: `exact-output`
const args = expression.args.map((arg, index) =>
canonicalizeExpression(
arg,
`${path}.args[${index}]`,
seen,
operandContext,
scope,
),
)
return canonicalizeFunction(expression.name, args)
}
if (expression.type === `agg`) {
return {
type: `agg`,
name: expression.name,
args: expression.args.map((arg, index) =>
canonicalizeExpression(
arg,
`${path}.args[${index}]`,
seen,
`exact-output`,
scope,
),
),
}
}
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,
`exact-output`,
scope,
),
value: canonicalizeSelectValue(
branch.value,
`${path}.branches[${index}].value`,
seen,
scope,
),
})),
}
if (expression.defaultValue !== undefined) {
result.defaultValue = canonicalizeSelectValue(
expression.defaultValue,
`${path}.defaultValue`,
seen,
scope,
)
}
return result
}
const childScope = createAliasScope(expression.query, scope)
const result: Record<string, StableIdentityValue> = {
type: `includesSubquery`,
query: canonicalizeQueryInScope(
expression.query,
`${path}.query`,
seen,
childScope,
),
correlationField: canonicalizeExpression(
expression.correlationField,
`${path}.correlationField`,
seen,
`equality-operand`,
scope,
),
childCorrelationField: canonicalizeExpression(
expression.childCorrelationField,
`${path}.childCorrelationField`,
seen,
`equality-operand`,
childScope,
),
fieldName: expression.fieldName,
materialization: expression.materialization,
}
if (expression.parentFilters) {
result.parentFilters = expression.parentFilters.map((where, index) =>
canonicalizeWhere(where, `${path}.parentFilters[${index}]`, seen, scope),
)
}
if (expression.parentProjection) {
result.parentProjection = expression.parentProjection.map(
(projection, index) =>
canonicalizeExpression(
projection,
`${path}.parentProjection[${index}]`,
seen,
`exact-output`,
scope,
),
)
}
if (expression.scalarField !== undefined) {
result.scalarField = expression.scalarField
}
return result
}
function canonicalizeFunction(
name: string,
args: Array<StableIdentityValue>,
): StableIdentityValue {
if ((name === `and` || name === `or`) && args.length > 0) {
const flattened = args.flatMap((arg) =>
isCanonicalFunction(arg, name) ? arg.args : [arg],
)
const unique = sortUniqueStableIdentityValues(flattened)
// The evaluator gives `and` and `or` boolean results even when their sole
// operand returns another truthy or falsy value. Keep that coercion in the
// identity because expression result types are erased at runtime.
return { type: `func`, name, args: unique }
}
if (name === `eq` && args.length === 2) {
args.sort(compareStableIdentityValues)
return { type: `func`, name, args }
}
if (
(name === `gt` || name === `gte` || name === `lt` || name === `lte`) &&
args.length === 2 &&
compareStableIdentityValues(args[0]!, args[1]!) > 0
) {
return {
type: `func`,
name: invertComparison(name),
args: [args[1]!, args[0]!],
}
}
return { type: `func`, name, args }
}
function sortUniqueStableIdentityValues(
values: Array<StableIdentityValue>,
): Array<StableIdentityValue> {
const keyedValues = values.map((value) => ({
key: JSON.stringify(value),
value,
}))
keyedValues.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
return keyedValues
.filter(
(entry, index) =>
index === 0 || entry.key !== keyedValues[index - 1]!.key,
)
.map((entry) => entry.value)
}
function isCanonicalFunction(
value: StableIdentityValue,
name: string,
): value is {
type: string
name: string
args: Array<StableIdentityValue>
} {
return (
value !== null &&
typeof value === `object` &&
!Array.isArray(value) &&
value.type === `func` &&
value.name === name &&
Array.isArray(value.args)
)
}
function invertComparison(
name: `gt` | `gte` | `lt` | `lte`,
): `gt` | `gte` | `lt` | `lte` {
switch (name) {
case `gt`:
return `lt`
case `gte`:
return `lte`
case `lt`:
return `gt`
case `lte`:
return `gte`
}
}
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 canonicalizeExactOutputRuntimeValue(
value: unknown,
path: string,
seen: WeakSet<object>,
): StableIdentityValue {
if (typeof value === `object` && value !== null) {
return getRuntimeReferenceIdentity(value)
}
return canonicalizeRuntimeValue(value, path, seen)
}
function canonicalizeEqualityRuntimeValue(
value: unknown,
path: string,
seen: WeakSet<object>,
scope?: AliasScope,
): StableIdentityValue {
if (isRefProxy(value)) {
return canonicalizeExpression(
toExpression(value),
path,
seen,
`equality-operand`,
scope,
)
}
if (typeof value === `number` && Object.is(value, -0)) {
return canonicalizeRuntimeValue(0, path, seen)
}
// Equality compares Uint8Array and Buffer values by content, independent of
// their concrete constructor and size.
const isUint8Array =
(typeof Buffer !== `undefined` && value instanceof Buffer) ||
value instanceof Uint8Array
if (isUint8Array) {
return [`binary`, `Uint8Array`, Array.from(value as Uint8Array)]
}
const normalized = normalizeValue(value)
if (normalized !== value) {
return canonicalizeRuntimeValue(normalized, path, seen)
}
if (typeof value === `object` && value !== null) {
return getRuntimeReferenceIdentity(value)
}
return canonicalizeRuntimeValue(value, path, seen)
}
function canonicalizeOrderingRuntimeValue(
value: unknown,
path: string,
seen: WeakSet<object>,
): StableIdentityValue {
if (typeof value === `number` && Object.is(value, -0)) {
return canonicalizeRuntimeValue(0, path, seen)
}
if (value instanceof Date && Number.isNaN(value.getTime())) {
return canonicalizeRuntimeValue(Number.NaN, path, seen)
}
const normalized = normalizeValue(value)
if (normalized !== value && !(value instanceof Uint8Array)) {
return canonicalizeRuntimeValue(normalized, path, seen)
}
try {
return canonicalizeRuntimeValue(value, path, seen)
} catch (error) {
if (
error instanceof UnhashableQueryIRError &&
typeof value === `object` &&
value !== null
) {
return getRuntimeReferenceIdentity(value)
}
throw error
}
}
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
}