Source

query/Statement.ts

import { Model } from "@decaf-ts/decorator-validation";
import type { Executor, RawExecutor } from "../interfaces";
import type {
  FromSelector,
  GroupBySelector,
  OrderBySelector,
  SelectSelector,
} from "./selectors";
import { Condition } from "./Condition";
import { prefixMethod } from "@decaf-ts/db-decorators";
import { final, toCamelCase } from "@decaf-ts/logging";
import type {
  CountOption,
  DistinctOption,
  LimitOption,
  MaxOption,
  MinOption,
  OffsetOption,
  OrderAndGroupOption,
  PreparableStatementExecutor,
  SelectOption,
  StatementExecutor,
  WhereOption,
} from "./options";
import { Paginatable } from "../interfaces/Paginatable";
import { Paginator } from "./Paginator";
import {
  Adapter,
  AdapterFlags,
  type ContextOf,
  PersistenceKeys,
  UnsupportedError,
} from "../persistence";
import { QueryError } from "./errors";
import { Logger } from "@decaf-ts/logging";
import { Constructor } from "@decaf-ts/decoration";
import {
  type ContextualArgs,
  ContextualLoggedClass,
  type MaybeContextualArg,
} from "../utils/index";
import { Context } from "../persistence/Context";
import { DirectionLimitOffset, PreparedStatement } from "./types";
import { QueryClause } from "./types";
import { GroupOperator, Operator, PreparedStatementKeys } from "./constants";
import { OrderDirection } from "../repository/constants";
import { Repository } from "../repository/Repository";
/**
 * @description Base class for database query statements
 * @summary Provides a foundation for building and executing database queries
 *
 * This abstract class implements the query builder pattern for constructing
 * database queries. It supports various query operations like select, from,
 * where, orderBy, groupBy, limit, and offset. It also provides methods for
 * executing queries and handling pagination.
 *
 * @template Q - The query type specific to the database adapter
 * @template M - The model type this statement operates on
 * @template R - The return type of the query
 * @param {Adapter<any, Q, any, any>} adapter - The database adapter to use for executing queries
 * @class Statement
 * @example
 * // Create a statement to query users
 * const statement = new SQLStatement(adapter);
 * const users = await statement
 *   .select()
 *   .from(User)
 *   .where(Condition.attribute("status").eq("active"))
 *   .orderBy(["createdAt", "DESC"])
 *   .limit(10)
 *   .execute();
 *
 * // Use pagination
 * const paginator = await statement
 *   .select()
 *   .from(User)
 *   .paginate(20); // 20 users per page
 *
 * @mermaid
 * sequenceDiagram
 *   participant Client
 *   participant Statement
 *   participant Adapter
 *   participant Database
 *
 *   Client->>Statement: select()
 *   Client->>Statement: from(Model)
 *   Client->>Statement: where(condition)
 *   Client->>Statement: orderBy([field, direction])
 *   Client->>Statement: limit(value)
 *   Client->>Statement: execute()
 *   Statement->>Statement: build()
 *   Statement->>Adapter: raw(query)
 *   Adapter->>Database: execute query
 *   Database-->>Adapter: return results
 *   Adapter-->>Statement: return processed results
 *   Statement-->>Client: return final results
 */
export abstract class Statement<
    M extends Model,
    A extends Adapter<any, any, any, any>,
    R,
    Q = A extends Adapter<any, any, infer Q, any> ? Q : never,
  >
  extends ContextualLoggedClass<ContextOf<A>>
  implements Executor<R>, RawExecutor<Q>, Paginatable<M, R, Q>
{
  protected readonly selectSelector?: SelectSelector<M>[];
  protected distinctSelector?: SelectSelector<M>;
  protected maxSelector?: SelectSelector<M>;
  protected minSelector?: SelectSelector<M>;
  protected countSelector?: SelectSelector<M>;
  protected fromSelector!: Constructor<M>;
  protected whereCondition?: Condition<M>;
  protected orderBySelector?: OrderBySelector<M>;
  protected groupBySelector?: GroupBySelector<M>;
  protected limitSelector?: number;
  protected offsetSelector?: number;

  protected prepared?: PreparedStatement<M>;

  protected constructor(
    protected adapter: Adapter<any, any, Q, any>,
    protected overrides?: Partial<AdapterFlags>
  ) {
    super();
    [this.execute, this.paginate].forEach((m) => {
      prefixMethod(
        this,
        m,
        async (...args: MaybeContextualArg<ContextOf<A>>) => {
          let execArgs = args;
          if (
            (!execArgs.length ||
              !(execArgs[execArgs.length - 1] instanceof Context)) &&
            this.fromSelector
          ) {
            const ctx = await this.adapter.context(
              PersistenceKeys.QUERY,
              this.overrides || {},
              this.fromSelector
            );
            execArgs = [...execArgs, ctx];
          }
          const { ctx, ctxArgs } = Adapter.logCtx<ContextOf<A>>(
            execArgs,
            m.name
          );

          const forceSimple = ctx.get("forcePrepareSimpleQueries");
          const forceComplex = ctx.get("forcePrepareComplexQueries");
          if ((forceSimple && this.isSimpleQuery()) || forceComplex)
            await this.prepare(ctx);
          return ctxArgs;
        },
        m.name
      );
    });
  }

  protected override get log(): Logger {
    return (this.adapter as any).log.for(Statement);
  }

  select<
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    S extends readonly SelectSelector<M>[],
  >(): SelectOption<M, M[]>;
  select<S extends readonly SelectSelector<M>[]>(
    selector: readonly [...S]
  ): SelectOption<M, Pick<M, S[number]>[]>;

  @final()
  select<S extends readonly SelectSelector<M>[]>(
    selector?: readonly [...S]
  ): SelectOption<M, M[]> | SelectOption<M, Pick<M, S[number]>[]> {
    Object.defineProperty(this, "selectSelector", {
      value: selector,
      writable: false,
    });
    return this as SelectOption<M, M[]> | SelectOption<M, Pick<M, S[number]>[]>;
  }

  @final()
  distinct<S extends SelectSelector<M>>(
    selector: S
  ): DistinctOption<M, M[S][]> {
    this.distinctSelector = selector;
    return this as DistinctOption<M, M[S][]>;
  }

  @final()
  max<S extends SelectSelector<M>>(selector: S): MaxOption<M, M[S]> {
    this.maxSelector = selector;
    return this as MaxOption<M, M[S]>;
  }

  @final()
  min<S extends SelectSelector<M>>(selector: S): MinOption<M, M[S]> {
    this.minSelector = selector;
    return this as MinOption<M, M[S]>;
  }

  @final()
  count<S extends SelectSelector<M>>(selector?: S): CountOption<M, number> {
    this.countSelector = selector;
    return this as CountOption<M, number>;
  }

  @final()
  public from(selector: FromSelector<M>): WhereOption<M, R> {
    this.fromSelector = (
      typeof selector === "string" ? Model.get(selector) : selector
    ) as Constructor<M>;
    if (!this.fromSelector)
      throw new QueryError(`Could not find selector model: ${selector}`);
    return this;
  }

  @final()
  public where(condition: Condition<M>): OrderAndGroupOption<M, R> {
    this.whereCondition = condition;
    return this;
  }

  @final()
  public orderBy(
    selector: OrderBySelector<M>
  ): LimitOption<M, R> & OffsetOption<M, R> {
    this.orderBySelector = selector;
    return this;
  }

  @final()
  public groupBy(selector: GroupBySelector<M>): LimitOption<M, R> {
    this.groupBySelector = selector;
    return this;
  }

  @final()
  public limit(value: number): OffsetOption<M, R> {
    this.limitSelector = value;
    return this;
  }

  @final()
  public offset(value: number): PreparableStatementExecutor<M, R> {
    this.offsetSelector = value;
    return this;
  }

  @final()
  async execute(...args: MaybeContextualArg<ContextOf<A>>): Promise<R> {
    try {
      if (this.prepared) return this.executePrepared(...args);
      const query: Q = this.build();
      return (await this.raw<R>(
        query,
        ...(args as ContextualArgs<ContextOf<A>>)
      )) as unknown as R;
    } catch (e: unknown) {
      throw new QueryError(e as Error);
    }
  }

  protected async executePrepared(
    ...argz: MaybeContextualArg<ContextOf<A>>
  ): Promise<R> {
    const repo = Repository.forModel(this.fromSelector, this.adapter.alias);
    const { method, args, params } = this.prepared as PreparedStatement<any>;
    return repo.statement(method, ...args, params, ...argz);
  }

  async raw<R>(rawInput: Q, ...args: ContextualArgs<ContextOf<A>>): Promise<R> {
    const { ctx, ctxArgs } = this.logCtx(args, this.raw);
    const allowRawStatements = ctx.get("allowRawStatements");
    if (!allowRawStatements)
      throw new UnsupportedError(
        "Raw statements are not allowed in the current configuration"
      );
    const results: R = await this.adapter.raw<R, true>(
      rawInput,
      true,
      ...ctxArgs
    );
    if (!this.selectSelector) {
      return results as unknown as R;
    }
    const pkAttr = Model.pk(this.fromSelector);

    const processor = function recordProcessor(
      this: Statement<M, A, R, Q>,
      r: any
    ) {
      const id = r[pkAttr];
      return this.adapter.revert(
        r,
        this.fromSelector as Constructor<any>,
        id,
        undefined,
        ctx
      ) as any;
    }.bind(this as any);

    if (Array.isArray(results)) return results.map(processor) as R;
    return processor(results) as R;
  }

  protected prepareCondition(condition: Condition<any>, ctx: ContextOf<A>) {
    // @ts-expect-error accessing protected properties
    // eslint-disable-next-line prefer-const
    let { attr1, operator, comparison } = condition;

    const result: PreparedStatement<any> = {} as any;
    switch (operator) {
      case GroupOperator.AND:
      case GroupOperator.OR: {
        let side1: string = attr1 as string,
          side2: string = comparison as any;
        if (typeof attr1 !== "string") {
          const condition1 = this.prepareCondition(
            attr1 as Condition<any>,
            ctx
          );
          side1 = condition1.method as string;
          result.args = [...(result.args || []), ...(condition1.args || [])];
        }

        if (comparison instanceof Condition) {
          const condition2 = this.prepareCondition(comparison, ctx);
          side2 = condition2.method as string;
          result.args = [...(result.args || []), ...(condition2.args || [])];
        }

        result.method = `${side1} ${operator.toLowerCase()} ${side2}`;
        break;
      }
      case Operator.EQUAL:
        result.method = attr1 as string;
        result.args = [...(result.args || []), comparison];
        break;
      case Operator.DIFFERENT:
        result.method = `${attr1} diff`;
        result.args = [...(result.args || []), comparison];
        break;
      case Operator.REGEXP:
        result.method = `${attr1} matches`;
        result.args = [...(result.args || []), comparison];
        break;
      case Operator.BIGGER:
        result.method = `${attr1} bigger`;
        result.args = [...(result.args || []), comparison];
        break;
      case Operator.BIGGER_EQ:
        result.method = `${attr1} bigger than equal`;
        break;
      case Operator.SMALLER:
        result.method = `${attr1} less`;
        result.args = [...(result.args || []), comparison];
        break;
      case Operator.SMALLER_EQ:
        result.method = `${attr1} less than equal`;
        result.args = [...(result.args || []), comparison];
        break;
      case Operator.IN:
        result.method = `${attr1} in`;
        result.args = [...(result.args || []), comparison];
        break;
      default:
        throw new QueryError(`Unsupported operator ${operator}`);
    }

    return result;
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  protected squash(ctx: ContextOf<A>): PreparedStatement<any> | undefined {
    if (this.selectSelector && this.selectSelector.length) return undefined;
    if (this.groupBySelector) return undefined;
    if (this.countSelector) return undefined;
    if (this.maxSelector) return undefined;
    if (this.minSelector) return undefined;

    let attrFromWhere: string | undefined;
    if (this.whereCondition) {
      // if (this.orderBySelector) return undefined;
      if (this.whereCondition["comparison"] instanceof Condition)
        return undefined;
      attrFromWhere = this.whereCondition["attr1"] as string;
    }

    const order: OrderBySelector<M> = this.orderBySelector
      ? this.orderBySelector
      : attrFromWhere
        ? [attrFromWhere as keyof M, OrderDirection.DSC]
        : [Model.pk(this.fromSelector), OrderDirection.DSC];

    const [attrFromOrderBy, sort] = order;

    const params: DirectionLimitOffset = {
      direction: sort,
    };

    if (this.limitSelector) params.limit = this.limitSelector;
    if (this.offsetSelector) params.offset = this.offsetSelector;

    const squashed: PreparedStatement<M> = {
      // listBy
      class: this.fromSelector,
      method: PreparedStatementKeys.LIST_BY,
      args: [attrFromOrderBy],
      params: params,
    } as PreparedStatement<M>;

    if (attrFromWhere) {
      // findBy
      squashed.method = PreparedStatementKeys.FIND_BY;
      squashed.args = [
        attrFromWhere,
        (this.whereCondition as Condition<M>)["comparison"] as string,
      ];
      squashed.params = params;
    }

    return squashed;
  }

  async prepare(ctx?: ContextOf<A>): Promise<StatementExecutor<M, R>> {
    ctx =
      ctx ||
      (await this.adapter.context(
        PersistenceKeys.QUERY,
        this.overrides || {},
        this.fromSelector
      ));

    if (
      this.isSimpleQuery() &&
      (ctx as ContextOf<A>).get("forcePrepareSimpleQueries")
    ) {
      const squashed = this.squash(ctx as ContextOf<A>);
      if (squashed) {
        this.prepared = squashed;
        return this;
      }
    }
    const args: (string | number)[] = [];
    const params: any = {} as any;

    const prepared: PreparedStatement<any> = {
      class: this.fromSelector,
      args,
      params,
    } as any;

    const method: string[] = [QueryClause.FIND_BY];

    if (this.whereCondition) {
      const parsed = this.prepareCondition(
        this.whereCondition,
        ctx as ContextOf<A>
      );
      method.push(parsed.method);
      if (parsed.args && parsed.args.length)
        args.push(...(parsed.args as (string | number)[]));
    }
    if (this.selectSelector)
      method.push(
        QueryClause.SELECT,
        this.selectSelector.join(` ${QueryClause.AND.toLowerCase()} `)
      );
    if (this.orderBySelector) {
      method.push(QueryClause.ORDER_BY, this.orderBySelector[0] as string);
      params.direction = this.orderBySelector[1];
    }
    if (this.groupBySelector)
      method.push(QueryClause.GROUP_BY, this.groupBySelector as string);
    if (this.limitSelector) params.limit = this.limitSelector;
    if (this.offsetSelector) {
      params.skip = this.offsetSelector;
    }
    prepared.method = toCamelCase(method.join(" "));
    prepared.params = params;
    this.prepared = prepared;
    return this;
  }

  protected isSimpleQuery() {
    return !(
      (this.selectSelector && this.selectSelector.length) ||
      this.groupBySelector ||
      this.countSelector ||
      this.maxSelector ||
      this.minSelector
    );
  }

  protected abstract build(): Q;
  protected abstract parseCondition(condition: Condition<M>, ...args: any[]): Q;

  /**
   * @description Creates a paginator for the query
   * @summary Builds the query and wraps it in a RamPaginator to enable pagination of results.
   * This allows retrieving large result sets in smaller chunks.
   * @param {number} size - The page size (number of results per page)
   * @return {Promise<Paginator<M, R, RawRamQuery<M>>>} A promise that resolves to a paginator for the query
   */
  async paginate(
    size: number,
    ...args: MaybeContextualArg<ContextOf<A>>
  ): Promise<Paginator<M, R, Q>> {
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const ctx = args.pop() as ContextOf<A>; // handled by prefix. kept for example for overrides
    try {
      return this.adapter.Paginator(
        this.prepared || this.build(),
        size,
        this.fromSelector
      );
    } catch (e: any) {
      throw new QueryError(e);
    }
  }

  override toString() {
    return `${this.adapter.flavour} statement`;
  }
}