import type { Bindable, QueryBuilder } from '@livestore/common' import { getDurationMsFromSpan, getResultSchema, isQueryBuilder, prepareBindValues, QueryBuilderAstSymbol, replaceSessionIdSymbol, SessionIdSymbol, UnknownError, } from '@livestore/common' import { deepEqual, objectToString, omitUndefineds, shouldNeverHappen } from '@livestore/utils' import { Equal, Hash, Predicate, Schema, TreeFormatter } from '@livestore/utils/effect' import * as otel from '@opentelemetry/api' import type { Thunk } from '../reactive.ts' import { isThunk, NOT_REFRESHED_YET } from '../reactive.ts' import type { RefreshReason } from '../store/store-types.ts' import { StoreInternalsSymbol } from '../store/store-types.ts' import { isValidFunctionString } from '../utils/function-string.ts' import type { DepKey, GetAtomResult, LiveQueryDef, ReactivityGraph, ReactivityGraphContext } from './base-class.ts' import { depsToString, LiveStoreQueryBase, makeGetAtomResult, withRCMap } from './base-class.ts' import { makeExecBeforeFirstRun, rowQueryLabel } from './client-document-get-query.ts' export type QueryInputRaw = { query: string schema: Schema.Schema bindValues?: Bindable /** * Can be provided explicitly to slightly speed up initial query performance * * NOTE In the future we want to do this automatically at build time */ queriedTables?: Set execBeforeFirstRun?: (ctx: ReactivityGraphContext) => void } export const isQueryInputRaw = (value: unknown): value is QueryInputRaw => Predicate.hasProperty(value, 'query') && Predicate.hasProperty(value, 'schema') export type QueryInput = QueryInputRaw | QueryBuilder /** * NOTE `queryDb` is only supposed to read data. Don't use it to insert/update/delete data but use events instead. * * When using contextual data when constructing the query, please make sure to include it in the `deps` option. * * @example * ```ts * const todos$ = queryDb(tables.todos.where({ complete: true })) * ``` * * @example * ```ts * // Group-by raw SQL query * const colorCounts$ = queryDb({ * query: sql`SELECT color, COUNT(*) as count FROM todos WHERE complete = ? GROUP BY color`, * schema: Schema.Array(Schema.Struct({ * color: Schema.String, * count: Schema.Number, * })), * bindValues: [1], * }) * ``` * * @example * ```ts * // Using contextual data when constructing the query * const makeFilteredQuery = (filter: string) => * queryDb(tables.todos.where({ title: { op: 'like', value: filter } }), { deps: [filter] }) * * const filteredTodos$ = makeFilteredQuery('buy coffee') * ``` */ export const queryDb: { ( queryInput: QueryInputRaw> | QueryBuilder, options?: { map?: (rows: TResultSchema) => TResult /** * Used for debugging / devtools */ label?: string deps?: DepKey }, ): LiveQueryDef // NOTE in this "thunk case", we can't directly derive label/queryInfo from the queryInput, // so the caller needs to provide them explicitly otherwise queryInfo will be set to `None`, // and label will be set during the query execution ( queryInput: | ((get: GetAtomResult) => QueryInputRaw>) | ((get: GetAtomResult) => QueryBuilder), options?: { map?: (rows: TResultSchema) => TResult /** * Used for debugging / devtools */ label?: string deps?: DepKey }, ): LiveQueryDef } = (queryInput, options) => { const { queryString, extraDeps } = getQueryStringAndExtraDeps(queryInput) const hash = [queryString, options?.deps !== undefined ? depsToString(options.deps) : undefined, depsToString(extraDeps)] .filter(Boolean) .join('-') if (isValidFunctionString(hash)._tag === 'invalid') { throw new Error(`On Expo/React Native, db queries must provide a \`deps\` option`) } if (hash.trim() === '') { return shouldNeverHappen('Invalid query hash for query:', objectToString(queryInput)) } const label = options?.label ?? queryString const def: LiveQueryDef = { _tag: 'def', make: withRCMap(hash, (ctx, otelContext) => { // TODO onDestroy return new LiveStoreDbQuery({ reactivityGraph: ctx.reactivityGraph.deref()!, queryInput, label, def, ...omitUndefineds({ map: options?.map, otelContext }), }) }), label, hash, [Equal.symbol](that: LiveQueryDef): boolean { return this.hash === that.hash }, [Hash.symbol](): number { return Hash.string(this.hash) }, } return def } const bindValuesToDepKey = (bindValues: Bindable | undefined): DepKey => { if (bindValues === undefined) { return [] } return Object.entries(bindValues) .map(([key, value]: [string, any]) => `${key}:${value === SessionIdSymbol ? 'SessionIdSymbol' : value}`) .join(',') } const getQueryStringAndExtraDeps = ( queryInput: QueryInput | ((get: GetAtomResult) => QueryInput), ): { queryString: string; extraDeps: DepKey } => { if (isQueryBuilder(queryInput) === true) { const { query, bindValues } = queryInput.asSql() return { queryString: query, extraDeps: bindValuesToDepKey(bindValues) } } if (isQueryInputRaw(queryInput) === true) { return { queryString: queryInput.query, extraDeps: bindValuesToDepKey(queryInput.bindValues) } } if (typeof queryInput === 'function') { return { queryString: queryInput.toString(), extraDeps: [] } } return shouldNeverHappen(`Invalid query input: ${String(queryInput)}`) } /* An object encapsulating a reactive SQL query */ export class LiveStoreDbQuery extends LiveStoreQueryBase { _tag = 'db' as const /** A reactive thunk representing the query text */ queryInput$: Thunk, ReactivityGraphContext, RefreshReason> | undefined /** A reactive thunk representing the query results */ results$: Thunk label: string readonly reactivityGraph private mapResult: (rows: TResultSchema) => TResult def: LiveQueryDef constructor({ queryInput, label: inputLabel, reactivityGraph, map, otelContext, def, }: { label?: string queryInput: | QueryInput> | ((get: GetAtomResult, ctx: ReactivityGraphContext) => QueryInput>) reactivityGraph: ReactivityGraph map?: (rows: TResultSchema) => TResult /** Only used for the initial query execution */ otelContext?: otel.Context def: LiveQueryDef }) { super() let label = inputLabel ?? 'db(unknown)' this.reactivityGraph = reactivityGraph this.def = def this.mapResult = map === undefined ? (rows: any) => rows as TResult : map const schemaRef: { current: Schema.Schema | undefined } = { current: typeof queryInput === 'function' ? undefined : isQueryBuilder(queryInput) === true ? undefined : queryInput.schema, } const execBeforeFirstRunRef: { current: ((ctx: ReactivityGraphContext, otelContext: otel.Context) => void) | undefined } = { current: undefined, } type TQueryInputRaw = QueryInputRaw let queryInputRaw$OrQueryInputRaw: TQueryInputRaw | Thunk const fromQueryBuilder = (qb: QueryBuilder.Any, otelContext: otel.Context | undefined) => { try { const qbRes = qb.asSql() const schema = getResultSchema(qb) as Schema.Schema> const ast = qb[QueryBuilderAstSymbol] return { queryInputRaw: { query: qbRes.query, schema, bindValues: qbRes.bindValues, queriedTables: new Set([ast.tableDef.sqliteDef.name]), } satisfies TQueryInputRaw, label: ast._tag === 'RowQuery' ? rowQueryLabel(ast.tableDef, ast.id) : qb.toString(), execBeforeFirstRun: ast._tag === 'RowQuery' ? makeExecBeforeFirstRun({ table: ast.tableDef, explicitDefaultValues: ast.explicitDefaultValues, id: ast.id, otelContext, }) : undefined, } } catch (cause) { throw new UnknownError({ cause, note: `Error building query for ${qb.toString()}`, payload: { qb } }) } } if (typeof queryInput === 'function') { queryInputRaw$OrQueryInputRaw = this.reactivityGraph.makeThunk( (get, setDebugInfo, ctx, otelContext) => { const startMs = performance.now() const queryInputResult = queryInput( makeGetAtomResult(get, ctx, otelContext ?? ctx.rootOtelContext, this.dependencyQueriesRef), ctx, ) const durationMs = performance.now() - startMs let queryInputRaw: TQueryInputRaw if (isQueryBuilder(queryInputResult) === true) { const res = fromQueryBuilder(queryInputResult, otelContext) queryInputRaw = res.queryInputRaw // setting label dynamically here this.label = res.label execBeforeFirstRunRef.current = res.execBeforeFirstRun } else { queryInputRaw = queryInputResult } setDebugInfo({ _tag: 'computed', label: `${this.label}:queryInput`, query: queryInputRaw.query, durationMs }) schemaRef.current = queryInputRaw.schema return queryInputRaw }, { label: `${label}:query`, meta: { liveStoreThunkType: 'db.query' }, // NOTE we're not checking the schema here as we assume the query string to always change when the schema might change equal: (a, b) => a.query === b.query && deepEqual(a.bindValues, b.bindValues), }, ) this.queryInput$ = queryInputRaw$OrQueryInputRaw } else { let queryInputRaw: TQueryInputRaw if (isQueryBuilder(queryInput) === true) { const res = fromQueryBuilder(queryInput, otelContext) queryInputRaw = res.queryInputRaw label = res.label execBeforeFirstRunRef.current = res.execBeforeFirstRun } else { queryInputRaw = queryInput } schemaRef.current = queryInputRaw.schema queryInputRaw$OrQueryInputRaw = queryInputRaw // this.label = inputLabel ? this.label : `db(${})` if (inputLabel === undefined && isQueryBuilder(queryInput) === true) { const ast = queryInput[QueryBuilderAstSymbol] if (ast._tag === 'RowQuery') { label = `db(${rowQueryLabel(ast.tableDef, ast.id)})` } } } const queriedTablesRef: { current: Set | undefined } = { current: undefined } const makeResultsEqual = (resultSchema: Schema.Schema) => { // Creating the equivalence function eagerly in outer scope as it might be expensive const eq = Schema.equivalence(resultSchema) return (a: TResult, b: TResult) => (a === NOT_REFRESHED_YET || b === NOT_REFRESHED_YET ? false : eq(a, b)) } // NOTE we try to create the equality function eagerly as it might be expensive // TODO also support derived equality for `map` (probably will depend on having an easy way to transform a schema without an `encode` step) // This would mean dropping the `map` option const resultsEqual = map === undefined ? schemaRef.current === undefined ? (a: TResult, b: TResult) => makeResultsEqual(schemaRef.current!)(a, b) : makeResultsEqual(schemaRef.current) : undefined const results$ = this.reactivityGraph.makeThunk( (get, setDebugInfo, queryContext, otelContext, debugRefreshReason) => queryContext.otelTracer.startActiveSpan( 'db:...', // NOTE span name will be overridden further down { attributes: { 'livestore.debugRefreshReason': Predicate.hasProperty(debugRefreshReason, 'label') === true ? (debugRefreshReason.label as string) : debugRefreshReason?._tag, }, }, otelContext ?? queryContext.rootOtelContext, (span) => { const otelContext = otel.trace.setSpan(otel.context.active(), span) const { store } = queryContext if (execBeforeFirstRunRef.current !== undefined) { execBeforeFirstRunRef.current(queryContext, otelContext) execBeforeFirstRunRef.current = undefined } const queryInputResult = isThunk(queryInputRaw$OrQueryInputRaw) === true ? (get(queryInputRaw$OrQueryInputRaw, otelContext, debugRefreshReason) as TQueryInputRaw) : (queryInputRaw$OrQueryInputRaw as TQueryInputRaw) const sqlString = queryInputResult.query const bindValues = queryInputResult.bindValues if (queriedTablesRef.current === undefined) { queriedTablesRef.current = store[StoreInternalsSymbol].sqliteDbWrapper.getTablesUsed(sqlString) } if (bindValues !== undefined) { replaceSessionIdSymbol(bindValues, store.sessionId) } // Establish a reactive dependency on the tables used in the query for (const tableName of queriedTablesRef.current) { const tableRef = store[StoreInternalsSymbol].tableRefs[tableName] ?? shouldNeverHappen(`No table ref found for ${tableName}`) get(tableRef, otelContext, debugRefreshReason) } span.setAttribute('sql.query', sqlString) span.updateName(`db:${sqlString.slice(0, 50)}`) const rawDbResults = store[StoreInternalsSymbol].sqliteDbWrapper.cachedSelect( sqlString, bindValues !== undefined ? prepareBindValues(bindValues, sqlString) : undefined, { otelContext, ...(queriedTablesRef.current !== undefined ? { queriedTables: queriedTablesRef.current } : {}), }, ) span.setAttribute('sql.rowsCount', rawDbResults.length) const parsedResult = Schema.decodeEither(schemaRef.current!)(rawDbResults) if (parsedResult._tag === 'Left') { const parseErrorStr = TreeFormatter.formatErrorSync(parsedResult.left) const expectedSchemaStr = String(schemaRef.current!.ast) const bindValuesStr = bindValues === undefined ? '' : `\nBind values: ${JSON.stringify(bindValues)}` return shouldNeverHappen( `\ Error parsing SQL query result (${label}). Query: ${sqlString}\ ${bindValuesStr} Expected schema: ${expectedSchemaStr} Error: ${parseErrorStr} Result:`, rawDbResults, '\n', ) } const result = this.mapResult(parsedResult.right) span.end() const durationMs = getDurationMsFromSpan(span) this.executionTimes.push(durationMs) setDebugInfo({ _tag: 'db', label: `${label}:results`, query: sqlString, durationMs }) return result }, ), { label: `${label}:results`, meta: { liveStoreThunkType: 'db.result' }, ...omitUndefineds({ equal: resultsEqual }), }, ) this.results$ = results$ this.label = label } destroy = () => { this.isDestroyed = true if (this.queryInput$ !== undefined) { this.reactivityGraph.destroyNode(this.queryInput$) } this.reactivityGraph.destroyNode(this.results$) for (const query of this.dependencyQueriesRef) { query.deref() } } }