UNPKG

@tanstack/optimistic

Version:

Core optimistic updates library

886 lines (805 loc) 25.4 kB
import type { Collection } from "../collection" import type { Comparator, Condition, From, JoinClause, Limit, LiteralValue, Offset, OrderBy, Query, Select, WithQuery, } from "./schema.js" import type { Context, Flatten, InferResultTypeFromSelectTuple, Input, InputReference, PropertyReference, PropertyReferenceString, RemoveIndexSignature, Schema, } from "./types.js" type CollectionRef = { [K: string]: Collection<any> } export class BaseQueryBuilder<TContext extends Context<Schema>> { private readonly query: Partial<Query<TContext>> = {} /** * Create a new QueryBuilder instance. */ constructor(query: Partial<Query<TContext>> = {}) { this.query = query } from<TCollectionRef extends CollectionRef>( collectionRef: TCollectionRef ): QueryBuilder<{ baseSchema: Flatten< TContext[`baseSchema`] & { [K in keyof TCollectionRef & string]: RemoveIndexSignature< (TCollectionRef[keyof TCollectionRef] extends Collection<infer T> ? T : never) & Input > } > schema: Flatten<{ [K in keyof TCollectionRef & string]: RemoveIndexSignature< (TCollectionRef[keyof TCollectionRef] extends Collection<infer T> ? T : never) & Input > }> default: keyof TCollectionRef & string }> from< T extends InputReference<{ baseSchema: TContext[`baseSchema`] schema: TContext[`baseSchema`] }>, >( collection: T ): QueryBuilder<{ baseSchema: TContext[`baseSchema`] schema: { [K in T]: RemoveIndexSignature<TContext[`baseSchema`][T]> } default: T }> from< T extends InputReference<{ baseSchema: TContext[`baseSchema`] schema: TContext[`baseSchema`] }>, TAs extends string, >( collection: T, as: TAs ): QueryBuilder<{ baseSchema: TContext[`baseSchema`] schema: { [K in TAs]: RemoveIndexSignature<TContext[`baseSchema`][T]> } default: TAs }> /** * Specify the collection to query from. * This is the first method that must be called in the chain. * * @param collection The collection name to query from * @param as Optional alias for the collection * @returns A new QueryBuilder with the from clause set */ from< T extends | InputReference<{ baseSchema: TContext[`baseSchema`] schema: TContext[`baseSchema`] }> | CollectionRef, TAs extends string | undefined, >(collection: T, as?: TAs) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (typeof collection === `object` && collection !== null) { return this.fromCollectionRef(collection) } else if (typeof collection === `string`) { return this.fromInputReference( collection as InputReference<{ baseSchema: TContext[`baseSchema`] schema: TContext[`baseSchema`] }>, as ) } else { throw new Error(`Invalid collection type`) } } private fromCollectionRef<TCollectionRef extends CollectionRef>( collectionRef: TCollectionRef ) { const keys = Object.keys(collectionRef) if (keys.length !== 1) { throw new Error(`Expected exactly one key`) } const key = keys[0]! const collection = collectionRef[key]! const newBuilder = new BaseQueryBuilder() Object.assign(newBuilder.query, this.query) newBuilder.query.from = key as From<TContext> newBuilder.query.collections ??= {} newBuilder.query.collections[key] = collection return newBuilder as unknown as QueryBuilder<{ baseSchema: TContext[`baseSchema`] & { [K in keyof TCollectionRef & string]: (TCollectionRef[keyof TCollectionRef] extends Collection< infer T > ? T : never) & Input } schema: { [K in keyof TCollectionRef & string]: (TCollectionRef[keyof TCollectionRef] extends Collection< infer T > ? T : never) & Input } default: keyof TCollectionRef & string }> } private fromInputReference< T extends InputReference<{ baseSchema: TContext[`baseSchema`] schema: TContext[`baseSchema`] }>, TAs extends string | undefined, >(collection: T, as?: TAs) { const newBuilder = new BaseQueryBuilder() Object.assign(newBuilder.query, this.query) newBuilder.query.from = collection as From<TContext> if (as) { newBuilder.query.as = as } // Calculate the result type without deep nesting type ResultSchema = TAs extends undefined ? { [K in T]: TContext[`baseSchema`][T] } : { [K in string & TAs]: TContext[`baseSchema`][T] } type ResultDefault = TAs extends undefined ? T : string & TAs // Use simpler type assertion to avoid excessive depth return newBuilder as unknown as QueryBuilder<{ baseSchema: TContext[`baseSchema`] schema: ResultSchema default: ResultDefault }> } /** * Specify what columns to select. * Overwrites any previous select clause. * * @param selects The columns to select * @returns A new QueryBuilder with the select clause set */ select<TSelects extends Array<Select<TContext>>>( this: QueryBuilder<TContext>, ...selects: TSelects ) { // Validate function calls in the selects // Need to use a type assertion to bypass deep recursive type checking const validatedSelects = selects.map((select) => { // If the select is an object with aliases, validate each value if ( typeof select === `object` && // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition select !== null && !Array.isArray(select) ) { const result: Record<string, any> = {} for (const [key, value] of Object.entries(select)) { // If it's a function call (object with a single key that is an allowed function name) if ( typeof value === `object` && value !== null && !Array.isArray(value) ) { const keys = Object.keys(value) if (keys.length === 1) { const funcName = keys[0]! // List of allowed function names from AllowedFunctionName const allowedFunctions = [ `SUM`, `COUNT`, `AVG`, `MIN`, `MAX`, `DATE`, `JSON_EXTRACT`, `JSON_EXTRACT_PATH`, `UPPER`, `LOWER`, `COALESCE`, `CONCAT`, `LENGTH`, `ORDER_INDEX`, ] if (!allowedFunctions.includes(funcName)) { console.warn( `Unsupported function: ${funcName}. Expected one of: ${allowedFunctions.join(`, `)}` ) } } } result[key] = value } return result } return select }) const newBuilder = new BaseQueryBuilder<TContext>( (this as BaseQueryBuilder<TContext>).query ) newBuilder.query.select = validatedSelects as Array<Select<TContext>> return newBuilder as QueryBuilder< Flatten< Omit<TContext, `result`> & { result: InferResultTypeFromSelectTuple<TContext, TSelects> } > > } /** * Add a where clause comparing two values. */ where( left: PropertyReferenceString<TContext> | LiteralValue, operator: Comparator, right: PropertyReferenceString<TContext> | LiteralValue ): QueryBuilder<TContext> /** * Add a where clause with a complete condition object. */ where(condition: Condition<TContext>): QueryBuilder<TContext> /** * Add a where clause to filter the results. * Can be called multiple times to add AND conditions. * * @param leftOrCondition The left operand or complete condition * @param operator Optional comparison operator * @param right Optional right operand * @returns A new QueryBuilder with the where clause added */ where( leftOrCondition: any, operator?: any, right?: any ): QueryBuilder<TContext> { // Create a new builder with a copy of the current query // Use simplistic approach to avoid deep type errors const newBuilder = new BaseQueryBuilder<TContext>() Object.assign(newBuilder.query, this.query) let condition: any // Determine if this is a complete condition or individual parts if (operator !== undefined && right !== undefined) { // Create a condition from parts condition = [leftOrCondition, operator, right] } else { // Use the provided condition directly condition = leftOrCondition } if (!newBuilder.query.where) { newBuilder.query.where = condition } else { // Create a composite condition with AND // Use any to bypass type checking issues const andArray: any = [newBuilder.query.where, `and`, condition] newBuilder.query.where = andArray } return newBuilder as unknown as QueryBuilder<TContext> } /** * Add a having clause comparing two values. * For filtering results after they have been grouped. */ having( left: PropertyReferenceString<TContext> | LiteralValue, operator: Comparator, right: PropertyReferenceString<TContext> | LiteralValue ): QueryBuilder<TContext> /** * Add a having clause with a complete condition object. * For filtering results after they have been grouped. */ having(condition: Condition<TContext>): QueryBuilder<TContext> /** * Add a having clause to filter the grouped results. * Can be called multiple times to add AND conditions. * * @param leftOrCondition The left operand or complete condition * @param operator Optional comparison operator * @param right Optional right operand * @returns A new QueryBuilder with the having clause added */ having( leftOrCondition: any, operator?: any, right?: any ): QueryBuilder<TContext> { // Create a new builder with a copy of the current query const newBuilder = new BaseQueryBuilder<TContext>() Object.assign(newBuilder.query, this.query) let condition: any // Determine if this is a complete condition or individual parts if (operator !== undefined && right !== undefined) { // Create a condition from parts condition = [leftOrCondition, operator, right] } else { // Use the provided condition directly condition = leftOrCondition } if (!newBuilder.query.having) { newBuilder.query.having = condition } else { // Create a composite condition with AND // Use any to bypass type checking issues const andArray: any = [newBuilder.query.having, `and`, condition] newBuilder.query.having = andArray } return newBuilder as QueryBuilder<TContext> } /** * Add a join clause to the query using a CollectionRef. */ join<TCollectionRef extends CollectionRef>(joinClause: { type: `inner` | `left` | `right` | `full` | `cross` from: TCollectionRef on: Condition< Flatten<{ baseSchema: TContext[`baseSchema`] schema: TContext[`schema`] & { [K in keyof TCollectionRef & string]: RemoveIndexSignature< (TCollectionRef[keyof TCollectionRef] extends Collection<infer T> ? T : never) & Input > } }> > where?: Condition< Flatten<{ baseSchema: TContext[`baseSchema`] schema: { [K in keyof TCollectionRef & string]: RemoveIndexSignature< (TCollectionRef[keyof TCollectionRef] extends Collection<infer T> ? T : never) & Input > } }> > }): QueryBuilder< Flatten< Omit<TContext, `schema`> & { schema: TContext[`schema`] & { [K in keyof TCollectionRef & string]: RemoveIndexSignature< (TCollectionRef[keyof TCollectionRef] extends Collection<infer T> ? T : never) & Input > } } > > /** * Add a join clause to the query without specifying an alias. * The collection name will be used as the default alias. */ join< T extends InputReference<{ baseSchema: TContext[`baseSchema`] schema: TContext[`baseSchema`] }>, >(joinClause: { type: `inner` | `left` | `right` | `full` | `cross` from: T on: Condition< Flatten<{ baseSchema: TContext[`baseSchema`] schema: TContext[`schema`] & { [K in T]: RemoveIndexSignature<TContext[`baseSchema`][T]> } }> > where?: Condition< Flatten<{ baseSchema: TContext[`baseSchema`] schema: { [K in T]: RemoveIndexSignature<TContext[`baseSchema`][T]> } }> > }): QueryBuilder< Flatten< Omit<TContext, `schema`> & { schema: TContext[`schema`] & { [K in T]: RemoveIndexSignature<TContext[`baseSchema`][T]> } } > > /** * Add a join clause to the query with a specified alias. */ join< TFrom extends InputReference<{ baseSchema: TContext[`baseSchema`] schema: TContext[`baseSchema`] }>, TAs extends string, >(joinClause: { type: `inner` | `left` | `right` | `full` | `cross` from: TFrom as: TAs on: Condition< Flatten<{ baseSchema: TContext[`baseSchema`] schema: TContext[`schema`] & { [K in TAs]: RemoveIndexSignature<TContext[`baseSchema`][TFrom]> } }> > where?: Condition< Flatten<{ baseSchema: TContext[`baseSchema`] schema: { [K in TAs]: RemoveIndexSignature<TContext[`baseSchema`][TFrom]> } }> > }): QueryBuilder< Flatten< Omit<TContext, `schema`> & { schema: TContext[`schema`] & { [K in TAs]: RemoveIndexSignature<TContext[`baseSchema`][TFrom]> } } > > join< TFrom extends | InputReference<{ baseSchema: TContext[`baseSchema`] schema: TContext[`baseSchema`] }> | CollectionRef, TAs extends string | undefined = undefined, >(joinClause: { type: `inner` | `left` | `right` | `full` | `cross` from: TFrom as?: TAs on: Condition< Flatten<{ baseSchema: TContext[`baseSchema`] schema: TContext[`schema`] & (TFrom extends CollectionRef ? { [K in keyof TFrom & string]: RemoveIndexSignature< (TFrom[keyof TFrom] extends Collection<infer T> ? T : never) & Input > } : TFrom extends InputReference<infer TRef> ? { [K in keyof TRef & string]: RemoveIndexSignature< TRef[keyof TRef] > } : never) }> > where?: Condition< Flatten<{ baseSchema: TContext[`baseSchema`] schema: TContext[`schema`] & (TFrom extends CollectionRef ? { [K in keyof TFrom & string]: RemoveIndexSignature< (TFrom[keyof TFrom] extends Collection<infer T> ? T : never) & Input > } : TFrom extends InputReference<infer TRef> ? { [K in keyof TRef & string]: RemoveIndexSignature< TRef[keyof TRef] > } : never) }> > }): QueryBuilder<any> { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (typeof joinClause.from === `object` && joinClause.from !== null) { return this.joinCollectionRef( joinClause as { type: `inner` | `left` | `right` | `full` | `cross` from: CollectionRef on: Condition<any> where?: Condition<any> } ) } else { return this.joinInputReference( joinClause as { type: `inner` | `left` | `right` | `full` | `cross` from: InputReference<{ baseSchema: TContext[`baseSchema`] schema: TContext[`baseSchema`] }> as?: TAs on: Condition<any> where?: Condition<any> } ) } } private joinCollectionRef<TCollectionRef extends CollectionRef>(joinClause: { type: `inner` | `left` | `right` | `full` | `cross` from: TCollectionRef on: Condition<any> where?: Condition<any> }): QueryBuilder<any> { // Create a new builder with a copy of the current query const newBuilder = new BaseQueryBuilder<TContext>() Object.assign(newBuilder.query, this.query) // Get the collection key const keys = Object.keys(joinClause.from) if (keys.length !== 1) { throw new Error(`Expected exactly one key in CollectionRef`) } const key = keys[0]! const collection = joinClause.from[key] if (!collection) { throw new Error(`Collection not found for key: ${key}`) } // Create a copy of the join clause for the query const joinClauseCopy = { type: joinClause.type, from: key, on: joinClause.on, where: joinClause.where, } as JoinClause<TContext> // Add the join clause to the query if (!newBuilder.query.join) { newBuilder.query.join = [joinClauseCopy] } else { newBuilder.query.join = [...newBuilder.query.join, joinClauseCopy] } // Add the collection to the collections map newBuilder.query.collections ??= {} newBuilder.query.collections[key] = collection // Return the new builder with updated schema type return newBuilder as QueryBuilder< Flatten< Omit<TContext, `schema`> & { schema: TContext[`schema`] & { [K in keyof TCollectionRef & string]: RemoveIndexSignature< (TCollectionRef[keyof TCollectionRef] extends Collection<infer T> ? T : never) & Input > } } > > } private joinInputReference< TFrom extends InputReference<{ baseSchema: TContext[`baseSchema`] schema: TContext[`baseSchema`] }>, TAs extends string | undefined = undefined, >(joinClause: { type: `inner` | `left` | `right` | `full` | `cross` from: TFrom as?: TAs on: Condition<any> where?: Condition<any> }): QueryBuilder<any> { // Create a new builder with a copy of the current query const newBuilder = new BaseQueryBuilder<TContext>() Object.assign(newBuilder.query, this.query) // Create a copy of the join clause for the query const joinClauseCopy = { ...joinClause } as JoinClause<TContext> // Add the join clause to the query if (!newBuilder.query.join) { newBuilder.query.join = [joinClauseCopy] } else { newBuilder.query.join = [...newBuilder.query.join, joinClauseCopy] } // Determine the alias or use the collection name as default const _effectiveAlias = joinClause.as ?? joinClause.from // Return the new builder with updated schema type return newBuilder as QueryBuilder< Flatten< Omit<TContext, `schema`> & { schema: TContext[`schema`] & { [K in typeof _effectiveAlias]: TContext[`baseSchema`][TFrom] } } > > } /** * Add an orderBy clause to sort the results. * Overwrites any previous orderBy clause. * * @param orderBy The order specification * @returns A new QueryBuilder with the orderBy clause set */ orderBy(orderBy: OrderBy<TContext>): QueryBuilder<TContext> { // Create a new builder with a copy of the current query const newBuilder = new BaseQueryBuilder<TContext>() Object.assign(newBuilder.query, this.query) // Set the orderBy clause newBuilder.query.orderBy = orderBy return newBuilder as QueryBuilder<TContext> } /** * Set a limit on the number of results returned. * * @param limit Maximum number of results to return * @returns A new QueryBuilder with the limit set */ limit(limit: Limit<TContext>): QueryBuilder<TContext> { // Create a new builder with a copy of the current query const newBuilder = new BaseQueryBuilder<TContext>() Object.assign(newBuilder.query, this.query) // Set the limit newBuilder.query.limit = limit return newBuilder as QueryBuilder<TContext> } /** * Set an offset to skip a number of results. * * @param offset Number of results to skip * @returns A new QueryBuilder with the offset set */ offset(offset: Offset<TContext>): QueryBuilder<TContext> { // Create a new builder with a copy of the current query const newBuilder = new BaseQueryBuilder<TContext>() Object.assign(newBuilder.query, this.query) // Set the offset newBuilder.query.offset = offset return newBuilder as QueryBuilder<TContext> } /** * Specify which column(s) to use as keys in the output keyed stream. * * @param keyBy The column(s) to use as keys * @returns A new QueryBuilder with the keyBy clause set */ keyBy( keyBy: PropertyReference<TContext> | Array<PropertyReference<TContext>> ): QueryBuilder<TContext> { // Create a new builder with a copy of the current query const newBuilder = new BaseQueryBuilder<TContext>() Object.assign(newBuilder.query, this.query) // Set the keyBy clause newBuilder.query.keyBy = keyBy return newBuilder as QueryBuilder<TContext> } /** * Add a groupBy clause to group the results by one or more columns. * * @param groupBy The column(s) to group by * @returns A new QueryBuilder with the groupBy clause set */ groupBy( groupBy: PropertyReference<TContext> | Array<PropertyReference<TContext>> ): QueryBuilder<TContext> { // Create a new builder with a copy of the current query const newBuilder = new BaseQueryBuilder<TContext>() Object.assign(newBuilder.query, this.query) // Set the groupBy clause newBuilder.query.groupBy = groupBy return newBuilder as QueryBuilder<TContext> } /** * Define a Common Table Expression (CTE) that can be referenced in the main query. * This allows referencing the CTE by name in subsequent from/join clauses. * * @param name The name of the CTE * @param queryBuilderCallback A function that builds the CTE query * @returns A new QueryBuilder with the CTE added */ with<TName extends string, TResult = Record<string, unknown>>( name: TName, queryBuilderCallback: ( builder: InitialQueryBuilder<{ baseSchema: TContext[`baseSchema`] schema: {} }> ) => QueryBuilder<any> ): InitialQueryBuilder<{ baseSchema: TContext[`baseSchema`] & { [K in TName]: TResult } schema: TContext[`schema`] }> { // Create a new builder with a copy of the current query const newBuilder = new BaseQueryBuilder<TContext>() Object.assign(newBuilder.query, this.query) // Create a new builder for the CTE const cteBuilder = new BaseQueryBuilder<{ baseSchema: TContext[`baseSchema`] schema: {} }>() // Get the CTE query from the callback const cteQueryBuilder = queryBuilderCallback( cteBuilder as InitialQueryBuilder<{ baseSchema: TContext[`baseSchema`] schema: {} }> ) // Get the query from the builder const cteQuery = cteQueryBuilder._query // Add an 'as' property to the CTE const withQuery: WithQuery<any> = { ...cteQuery, as: name, } // Add the CTE to the with array if (!newBuilder.query.with) { newBuilder.query.with = [withQuery] } else { newBuilder.query.with = [...newBuilder.query.with, withQuery] } // Use a type cast that simplifies the type structure to avoid recursion return newBuilder as unknown as InitialQueryBuilder<{ baseSchema: TContext[`baseSchema`] & { [K in TName]: TResult } schema: TContext[`schema`] }> } get _query(): Query<TContext> { return this.query as Query<TContext> } } export type InitialQueryBuilder<TContext extends Context<Schema>> = Pick< BaseQueryBuilder<TContext>, `from` | `with` > export type QueryBuilder<TContext extends Context<Schema>> = Omit< BaseQueryBuilder<TContext>, `from` > /** * Create a new query builder with the given schema */ export function queryBuilder<TBaseSchema extends Schema = {}>() { return new BaseQueryBuilder<{ baseSchema: TBaseSchema schema: {} }>() as InitialQueryBuilder<{ baseSchema: TBaseSchema schema: {} }> } export type ResultsFromContext<TContext extends Context<Schema>> = Flatten< TContext[`result`] extends object ? TContext[`result`] : TContext[`result`] extends undefined ? TContext[`schema`] : object > export type ResultFromQueryBuilder<TQueryBuilder> = Flatten< TQueryBuilder extends QueryBuilder<infer C> ? C extends { result: infer R } ? R : never : never >