UNPKG

@arkiv-network/sdk

Version:

TypeScript client library for Arkiv's blockchains interactions

154 lines (143 loc) • 6.22 kB
import type { Hex } from "viem" import type { ArkivClient } from "../clients/baseClient" import { addr, type ColumnsOf, SCHEMA_ATTRIBUTE, type Schema } from "../schema" import { InvalidPredicateError } from "./errors" import { and, checkAgainstSchema, type Expression, eq, type RefsOf, type SchemaRef, } from "./expression" /** The expressions in a `where` call — passed one by one, or as one array. */ type WhereExpressions<E extends readonly (Expression | readonly Expression[])[]> = E[number] extends infer X ? (X extends readonly Expression[] ? X[number] : X) : never /** * Human-readable messages for compile-time errors when a filter does not fit the schema. */ type FilterProblem<R, S extends Schema> = R extends { readonly name: infer N extends string readonly type: infer T extends string } ? R extends SchemaRef<S> ? never : N extends SchemaRef<S>["name"] ? `"${N}" has type ${Extract<SchemaRef<S>, { name: N }>["type"]}, but is compared against a value of type ${T}` : N extends keyof ColumnsOf<S> ? ColumnsOf<S>[N]["type"] extends "bytes" ? `"${N}" is a bytes column, and bytes are never indexed, so it cannot be filtered on. Index a hash of it, as a hex str, instead` : `"${N}" is not indexed, so it cannot be filtered on. Declare it ${ColumnsOf<S>[N]["type"]}().indexed() to filter on it` : `"${N}" is not a column of this schema` : never /** * `where`'s check that every filter fits the schema — `unknown` when it does, or when there is no * schema, and otherwise an object whose one property is named after the problem. No expression has * that property, so the compiler reports the sentence as the property it is missing. */ type FilterCheck< E extends readonly (Expression | readonly Expression[])[], TSchema, > = TSchema extends Schema ? [FilterProblem<RefsOf<WhereExpressions<E>>, TSchema>] extends [infer P extends string] ? [P] extends [never] ? unknown : { [K in P]: never } : unknown : unknown /** * What every query builder shares: the client, the filters — `where`, `ownedBy`, `createdBy` and a * named schema's tag — and the block to read at. `client.select()` and `client.count()` build on it, * so the same filters always send the same query string, whichever of them runs it. * * Not constructed directly: create a builder with `client.select(...)` or `client.count()`. * * @typeParam TSchema - The schema named with `from()`, if any, which `where` is checked against. */ export abstract class QueryBuilderBase<TSchema extends Schema | undefined = undefined> { protected readonly _client: ArkivClient protected _schema: Schema | undefined protected readonly _filters: Expression[] = [] protected _ownedBy: Expression | undefined protected _createdBy: Expression | undefined constructor(client: ArkivClient) { this._client = client } /** * This builder's state in a new builder of the same class, re-typed by the caller. Every field is * carried over, so one a subclass adds is not dropped by `from()`; the filter list, which is * mutated in place, is copied rather than shared. * * A copy, not `this` re-typed: a builder typed for one schema must never be the same object as * one typed for another, or `base.from(Posts)` and `base.from(Users)` would share their state. */ protected copy<B extends QueryBuilderBase<Schema | undefined>>(): B { const copy = Object.create(Object.getPrototypeOf(this)) as B return Object.assign(copy, this, { _filters: [...this._filters] }) } /** * Adds filters. Everything passed here, and across repeated calls, must hold. * * After `from()`, every attribute a filter names must be an indexed column of the schema, * compared against a value of its type — anything else is a compile error. * * @param expressions - The expressions, as separate arguments or one array. * * @example * builder.where(eq("category", "docs")) * builder.where(gte("level", i32(10)), lt("level", i32(20))) * builder.where(or(eq("status", "open"), exists("reviewedBy"))) */ where<const E extends readonly (Expression | readonly Expression[])[]>( ...expressions: E & FilterCheck<E, TSchema> ): this { this._filters.push(...expressions.flat()) return this } /** * Restricts the query to entities this account owns — shorthand for * `where(eq("$owner", addr(owner)))`. Calling it again replaces the filter. * * @example * builder.ownedBy("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") */ ownedBy(owner: Hex): this { this._ownedBy = eq("$owner", addr(owner)) return this } /** * Restricts the query to entities this account created — shorthand for * `where(eq("$creator", addr(creator)))`. Calling it again replaces the filter. * * The creator never changes; the owner does, so these differ after a transfer. */ createdBy(creator: Hex): this { this._createdBy = eq("$creator", addr(creator)) return this } /** * The query string this builder will send — the same text the node parses: its filters, * `ownedBy` and `createdBy`, and a named schema's tag, ANDed. * * @throws {InvalidPredicateError} If no filter has been added, or — after `from()` — a filter * does not fit the schema. A named schema's own tag filter counts as a filter. */ toString(): string { const filters = [...this._filters] if (this._ownedBy) filters.push(this._ownedBy) if (this._createdBy) filters.push(this._createdBy) const schema = this._schema if (schema !== undefined) { for (const filter of filters) checkAgainstSchema(filter, schema) // Added after the check: the tag is not a column, and the caller never wrote this filter. if (schema.tag !== undefined) filters.unshift(eq(SCHEMA_ATTRIBUTE, schema.tag)) } if (filters.length === 0) { throw new InvalidPredicateError( "A query needs at least one filter — the language has no spelling for 'match every " + "entity'. Add a where(...) predicate, or narrow by ownedBy(...) / createdBy(...).", ) } return and(filters).toString() } }