@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
154 lines (143 loc) • 6.22 kB
text/typescript
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()
}
}