Source

query/Statement.ts

import { Model } from "@decaf-ts/decorator-validation";
import {
  enforceDBDecorators,
  OperationKeys,
} from "@decaf-ts/db-decorators";
import type { Executor, RawExecutor } from "../interfaces";
import type {
  FromSelector,
  GroupBySelector,
  OrderBySelector,
  OrderDirectionInput,
  SelectSelector,
} from "./selectors";
import { Condition } from "./Condition";
import { prefixMethod } from "@decaf-ts/db-decorators";
import { final, Logger, toCamelCase } from "@decaf-ts/logging";
import type {
  CountDistinctOption,
  CountOption,
  DistinctOption,
  GroupByResult,
  MaxOption,
  MinOption,
  OffsetOption,
  OrderAndGroupOption,
  OrderByResult,
  OrderByThenByOption,
  PreparableStatementExecutor,
  SelectOption,
  StatementExecutor,
  SumOption,
  AvgOption,
  WhereOption,
} from "./options";
import { Paginatable } from "../interfaces/Paginatable";
import { Paginator } from "./Paginator";
import { Adapter } from "../persistence/Adapter";
import type { AdapterFlags, ContextOf } from "../persistence/types";
import { PersistenceKeys } from "../persistence/constants";
import { UnsupportedError } from "../persistence/errors";
import { QueryError } from "./errors";
import { Constructor } from "@decaf-ts/decoration";
import {
  type ContextualArgs,
  ContextualLoggedClass,
  type MaybeContextualArg,
} from "../utils/ContextualLoggedClass";
import { DirectionLimitOffset, PreparedStatement, 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> | null;
  protected countDistinctSelector?: SelectSelector<M> | null;
  protected sumSelector?: SelectSelector<M>;
  protected avgSelector?: SelectSelector<M>;
  protected _inCountMode: boolean = false;
  protected fromSelector!: Constructor<M>;
  protected whereCondition?: Condition<M>;
  protected orderBySelectors?: OrderBySelector<M>[];
  protected groupBySelectors?: 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,
        (...args: MaybeContextualArg<ContextOf<A>>) => {
          return this.executionPrefix(m, ...args);
        },
        m.name
      );
    });
  }

  protected async executionPrefix(
    method: any,
    ...args: MaybeContextualArg<ContextOf<A>>
  ) {
    const { ctx, ctxArgs, log } = (
      await this.adapter["logCtx"](
        [this.fromSelector, ...args],
        method.name === this.paginate.name
          ? PreparedStatementKeys.PAGE_BY
          : PersistenceKeys.QUERY,
        true,
        this.overrides || {}
      )
    ).for(method);

    ctxArgs.shift();

    const forceSimple = ctx.get("forcePrepareSimpleQueries");
    const forceComplex = ctx.get("forcePrepareComplexQueries");
    log.silly(
      `statement force simple ${forceSimple}, forceComplex: ${forceComplex}`
    );
    // Simple queries or simple aggregation queries (aggregations without where conditions)
    // Also exclude multi-level groupBy from simple aggregation squashing
    const isSimpleAggregation =
      this.hasAggregation() &&
      !this.whereCondition &&
      !this.selectSelector?.length &&
      (this.groupBySelectors?.length || 0) <= 1;
    if (
      (forceSimple && (this.isSimpleQuery() || isSimpleAggregation)) ||
      forceComplex
    ) {
      log.silly(
        `squashing ${!forceComplex ? "simple" : "complex"} query to prepared statement`
      );
      await this.prepare(ctx);
      log.silly(
        `squashed ${!forceComplex ? "simple" : "complex"} query to ${JSON.stringify(this.prepared, null, 2)}`
      );
    }
    return ctxArgs;
  }

  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]>[]>;
  }

  // Overload for standalone distinct - requires a selector
  distinct<S extends SelectSelector<M>>(selector: S): DistinctOption<M, M[S][]>;
  // Overload for count().distinct() - no selector needed
  distinct(): CountDistinctOption<M>;

  @final()
  distinct<S extends SelectSelector<M>>(
    selector?: S
  ): DistinctOption<M, M[S][]> | CountDistinctOption<M> {
    // When chained after count(), make it a count distinct
    if (this._inCountMode) {
      // Use the count selector as the field to count distinct on
      this.countDistinctSelector = this.countSelector;
      this.countSelector = undefined;
      this._inCountMode = false;
      return this as unknown as CountDistinctOption<M>;
    }
    // Standalone distinct requires a selector
    if (!selector) {
      throw new QueryError(
        "distinct() requires a selector when not chained after count()"
      );
    }
    this.distinctSelector = selector;
    return this as unknown 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()
  sum<S extends SelectSelector<M>>(selector: S): SumOption<M, number> {
    this.sumSelector = selector;
    return this as SumOption<M, number>;
  }

  @final()
  avg<S extends SelectSelector<M>>(selector: S): AvgOption<M, number> {
    this.avgSelector = selector;
    return this as AvgOption<M, number>;
  }

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

  @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;
  }

  public orderBy(selector: OrderBySelector<M>): OrderByResult<M, R>;
  public orderBy(
    attribute: keyof M,
    direction: OrderDirectionInput
  ): OrderByResult<M, R>;

  @final()
  public orderBy(
    selectorOrAttribute: OrderBySelector<M> | keyof M,
    direction?: OrderDirectionInput
  ): OrderByResult<M, R> {
    this.orderBySelectors = [
      this.normalizeOrderCriterion(selectorOrAttribute, direction),
    ];
    return this as OrderByResult<M, R>;
  }

  public thenBy(selector: GroupBySelector<M>): GroupByResult<M>;
  public thenBy(selector: OrderBySelector<M>): OrderByThenByOption<M, R>;
  public thenBy(
    attribute: keyof M,
    direction: OrderDirectionInput
  ): OrderByThenByOption<M, R>;

  @final()
  public thenBy(
    selectorOrAttribute: OrderBySelector<M> | keyof M,
    direction?: OrderDirectionInput
  ): OrderByThenByOption<M, R> | GroupByResult<M> {
    const isOrderingCriterion =
      Array.isArray(selectorOrAttribute) || typeof direction !== "undefined";
    if (isOrderingCriterion) {
      if (!this.orderBySelectors || !this.orderBySelectors.length)
        throw new QueryError("thenBy requires orderBy to be called first");
      this.orderBySelectors.push(
        this.normalizeOrderCriterion(selectorOrAttribute, direction)
      );
      return this as unknown as OrderByThenByOption<M, R>;
    }
    if (!this.groupBySelectors || !this.groupBySelectors.length)
      throw new QueryError(
        "groupBy must be called before chaining group selectors"
      );
    this.groupBySelectors.push(selectorOrAttribute as GroupBySelector<M>);
    return this as unknown as GroupByResult<M>;
  }

  private normalizeOrderCriterion(
    selectorOrAttribute: OrderBySelector<M> | keyof M,
    direction?: OrderDirectionInput
  ): OrderBySelector<M> {
    if (Array.isArray(selectorOrAttribute)) {
      const [attribute, dir] = selectorOrAttribute;
      return [attribute, this.normalizeOrderDirection(dir)];
    }
    return [selectorOrAttribute, this.normalizeOrderDirection(direction)];
  }

  private normalizeOrderDirection(
    direction?: OrderDirectionInput
  ): OrderDirection {
    if (!direction) {
      throw new QueryError(
        "orderBy direction is required when specifying the attribute separately."
      );
    }
    const normalized = String(direction).toLowerCase();
    if (normalized === OrderDirection.ASC) return OrderDirection.ASC;
    if (normalized === OrderDirection.DSC) return OrderDirection.DSC;
    throw new QueryError(
      `Invalid OrderBy direction ${direction}. Expected one of: ${Object.values(OrderDirection).join(", ")}.`
    );
  }

  @final()
  public groupBy<Key extends GroupBySelector<M>>(
    selector: Key
  ): GroupByResult<M, [Key]> {
    if (this.orderBySelectors && this.orderBySelectors.length) {
      throw new QueryError("groupBy must be called before orderBy.");
    }
    this.groupBySelectors = [selector];
    return this as unknown as GroupByResult<M, [Key]>;
  }

  @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> {
    const { log, ctx, ctxArgs } = this.logCtx(args, this.execute);
    try {
      if (this.prepared) return this.executePrepared(...(args as any));
      log.silly(`Building raw statement...`);
      const query: Q = this.build();
      log.silly(`executing raw statement`);
      const results = (await this.raw<R>(
        query,
        ...(ctxArgs as ContextualArgs<ContextOf<A>>)
      )) as unknown as R;
      if (this.hasAggregation()) {
        return results;
      }
      if (!this.selectSelector) {
        const processor = (r: any) => this.processRecord(r, ctx);

        if (this.groupBySelectors?.length) {
          const grouped = this.revertGroupedResults(results, processor) as R;
          return (await this.applyAfterHandlersToResult(grouped, ctx)) as R;
        }
        if (Array.isArray(results)) {
          const mapped = results.map(processor) as unknown as R;
          return (await this.applyAfterHandlersToResult(mapped, ctx)) as R;
        }
        const single = processor(results) as unknown as R;
        return (await this.applyAfterHandlersToResult(single, ctx)) as R;
      }
      return results;
    } catch (e: unknown) {
      throw new QueryError(e as Error);
    }
  }

  protected revertGroupedResults(
    value: any,
    processor: (record: any) => any
  ): any {
    if (Array.isArray(value)) return value.map(processor);
    if (value && typeof value === "object") {
      return Object.entries(value).reduce<Record<string, any>>(
        (acc, [key, val]) => {
          acc[key] = this.revertGroupedResults(val, processor);
          return acc;
        },
        {}
      );
    }
    return value;
  }

  protected async executePrepared(
    ...argz: ContextualArgs<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.hasAggregation()) {
      return results;
    }
    if (!this.selectSelector) {
      return results as unknown as R;
    }

    const processor = (r: any) => this.processRecord(r, ctx);

    if (Array.isArray(results)) {
      const mapped = results.map(processor) as unknown as R;
      return (await this.applyAfterHandlersToResult(mapped, ctx)) as R;
    }
    const single = processor(results) as unknown as R;
    return (await this.applyAfterHandlersToResult(single, ctx)) as R;
  }

  protected processRecord(record: any, ctx: ContextOf<A>): M {
    const pkAttr = Model.pk(this.fromSelector);
    const id = record[pkAttr];
    return this.adapter.revert(
      record,
      this.fromSelector as Constructor<any>,
      id,
      undefined,
      ctx
    ) as M;
  }

  protected async applyAfterHandlersToResult(
    value: any,
    ctx: ContextOf<A>
  ): Promise<any> {
    if (!ctx.getOrUndefined("afterQueryHandlers"))
      return value;
    if (value instanceof Model) {
      await enforceDBDecorators(
        this.getRepository(),
        ctx,
        value,
        OperationKeys.READ,
        OperationKeys.AFTER
      );
      return value;
    }
    if (Array.isArray(value)) {
      await Promise.all(
        value.map((entry) => this.applyAfterHandlersToResult(entry, ctx))
      );
      return value;
    }
    if (value && typeof value === "object") {
      await Promise.all(
        Object.entries(value).map(([key, entry]) =>
          this.applyAfterHandlersToResult(entry, ctx).then((processed) => {
            value[key] = processed;
          })
        )
      );
      return value;
    }
    return value;
  }

  protected getRepository(): Repository<M, A> {
    return Repository.forModel(this.fromSelector, this.adapter.alias);
  }

  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 {
    const defaultQuery = this.matchDefaultQueryCondition();
    if (defaultQuery) {
      const direction = this.getOrderDirection();
      return {
        class: this.fromSelector,
        method: PreparedStatementKeys.FIND,
        args: [defaultQuery.value, direction],
        params: {
          direction,
        },
      } as PreparedStatement<M>;
    }

    // If there's a where condition with complex conditions (nested Conditions), can't squash
    if (this.whereCondition) {
      if (this.whereCondition["comparison"] instanceof Condition)
        return undefined;
    }

    // Try to squash simple aggregation queries without where conditions
    if (!this.whereCondition && !this.selectSelector?.length) {
      // Count query
      if (
        typeof this.countSelector !== "undefined" &&
        !this.countDistinctSelector
      ) {
        return {
          class: this.fromSelector,
          method: PreparedStatementKeys.COUNT_OF,
          args: this.countSelector !== null ? [this.countSelector] : [],
          params: {},
        } as PreparedStatement<M>;
      }

      // Max query
      if (this.maxSelector) {
        return {
          class: this.fromSelector,
          method: PreparedStatementKeys.MAX_OF,
          args: [this.maxSelector],
          params: {},
        } as PreparedStatement<M>;
      }

      // Min query
      if (this.minSelector) {
        return {
          class: this.fromSelector,
          method: PreparedStatementKeys.MIN_OF,
          args: [this.minSelector],
          params: {},
        } as PreparedStatement<M>;
      }

      // Avg query
      if (this.avgSelector) {
        return {
          class: this.fromSelector,
          method: PreparedStatementKeys.AVG_OF,
          args: [this.avgSelector],
          params: {},
        } as PreparedStatement<M>;
      }

      // Sum query
      if (this.sumSelector) {
        return {
          class: this.fromSelector,
          method: PreparedStatementKeys.SUM_OF,
          args: [this.sumSelector],
          params: {},
        } as PreparedStatement<M>;
      }

      // Distinct query
      if (this.distinctSelector) {
        return {
          class: this.fromSelector,
          method: PreparedStatementKeys.DISTINCT_OF,
          args: [this.distinctSelector],
          params: {},
        } as PreparedStatement<M>;
      }

      // Group by query (simple single-level grouping)
      if (this.groupBySelectors?.length === 1) {
        return {
          class: this.fromSelector,
          method: PreparedStatementKeys.GROUP_OF,
          args: [this.groupBySelectors[0]],
          params: {},
        } as PreparedStatement<M>;
      }
    }

    // Can't squash complex queries with select/groupBy/aggregations that have where conditions
    if (this.selectSelector && this.selectSelector.length) return undefined;
    if (this.groupBySelectors && this.groupBySelectors.length) return undefined;
    if (typeof this.countSelector !== "undefined") return undefined;
    if (this.countDistinctSelector) return undefined;
    if (this.maxSelector) return undefined;
    if (this.minSelector) return undefined;
    if (this.sumSelector) return undefined;
    if (this.avgSelector) return undefined;

    let attrFromWhere: string | undefined;
    if (this.whereCondition) {
      attrFromWhere = this.whereCondition["attr1"] as string;
    }

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

    const [attrFromOrderBy, sort] = order;

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

    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;
  }

  private matchDefaultQueryCondition():
    | {
        value: string;
        attributes: string[];
      }
    | undefined {
    if (!this.whereCondition) return undefined;
    const found = this.extractDefaultStartsWithAttributes(this.whereCondition);
    if (!found) return undefined;
    const defaultAttrs = Model.defaultQueryAttributes(this.fromSelector);
    if (!defaultAttrs || !defaultAttrs.length) return undefined;
    const normalizedDefault = Array.from(new Set(defaultAttrs.map(String)));
    const normalizedFound = Array.from(new Set(found.attributes.map(String)));
    if (normalizedDefault.length !== normalizedFound.length) return undefined;
    if (normalizedDefault.every((attr) => normalizedFound.includes(attr))) {
      return {
        value: found.value,
        attributes: normalizedDefault,
      };
    }
    return undefined;
  }

  private extractDefaultStartsWithAttributes(
    condition: Condition<M>
  ): { attributes: string[]; value: string } | undefined {
    const collected = this.collectStartsWithAttributes(condition);
    if (!collected) return undefined;
    return {
      attributes: Array.from(new Set(collected.attributes)),
      value: collected.value,
    };
  }

  private collectStartsWithAttributes(
    condition: Condition<M> | undefined
  ): { attributes: string[]; value: string } | undefined {
    if (!condition) return undefined;
    const { attr1, operator, comparison } = condition as unknown as {
      attr1: string | Condition<M>;
      operator: Operator | GroupOperator;
      comparison: any;
    };
    if (operator === Operator.STARTS_WITH) {
      if (typeof attr1 !== "string" || typeof comparison !== "string")
        return undefined;
      return {
        attributes: [attr1],
        value: comparison,
      };
    }
    if (operator === GroupOperator.OR) {
      const left =
        attr1 instanceof Condition
          ? this.collectStartsWithAttributes(attr1 as Condition<M>)
          : undefined;
      const right =
        comparison instanceof Condition
          ? this.collectStartsWithAttributes(comparison as Condition<M>)
          : undefined;
      if (left && right && left.value === right.value) {
        return {
          attributes: [...left.attributes, ...right.attributes],
          value: left.value,
        };
      }
      return undefined;
    }
    if (operator === GroupOperator.AND) {
      const left =
        attr1 instanceof Condition
          ? this.collectStartsWithAttributes(attr1 as Condition<M>)
          : undefined;
      if (left) return left;
      const right =
        comparison instanceof Condition
          ? this.collectStartsWithAttributes(comparison as Condition<M>)
          : undefined;
      return right;
    }
    return undefined;
  }

  private getOrderDirection(): OrderDirection {
    return (
      (this.orderBySelectors?.[0]?.[1] as OrderDirection) ?? OrderDirection.ASC
    );
  }

  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;
      }
    }

    // Also try to squash aggregation queries
    if (
      (ctx as ContextOf<A>).get("forcePrepareSimpleQueries") ||
      (ctx as ContextOf<A>).get("forcePrepareComplexQueries")
    ) {
      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;

    // Determine the method prefix based on the query type
    let methodPrefix: string = QueryClause.FIND_BY;
    let selectorField: string | undefined;

    if (typeof this.countSelector !== "undefined") {
      methodPrefix = QueryClause.COUNT_BY;
      selectorField =
        this.countSelector !== null
          ? (this.countSelector as string)
          : undefined;
    } else if (this.sumSelector) {
      methodPrefix = QueryClause.SUM_BY;
      selectorField = this.sumSelector as string;
    } else if (this.avgSelector) {
      methodPrefix = QueryClause.AVG_BY;
      selectorField = this.avgSelector as string;
    } else if (this.minSelector) {
      methodPrefix = QueryClause.MIN_BY;
      selectorField = this.minSelector as string;
    } else if (this.maxSelector) {
      methodPrefix = QueryClause.MAX_BY;
      selectorField = this.maxSelector as string;
    } else if (this.distinctSelector) {
      methodPrefix = QueryClause.DISTINCT_BY;
      selectorField = this.distinctSelector as string;
    } else if (
      this.groupBySelectors?.length &&
      !this.selectSelector?.length &&
      !this.whereCondition
    ) {
      // Group-only query (no select, no where)
      methodPrefix = QueryClause.GROUP_BY_PREFIX;
      selectorField = this.groupBySelectors[0] as string;
    }
    // If there's a where condition or selectSelector, use findBy prefix even with groupBy

    const method: string[] = [methodPrefix];
    if (selectorField) {
      method.push(selectorField);
    }

    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.orderBySelectors?.length) {
      const [primary, ...secondary] = this.orderBySelectors;
      method.push(QueryClause.ORDER_BY, primary[0] as string);
      params.direction = primary[1];
      if (secondary.length) {
        params.order = this.orderBySelectors.map(([attr, dir]) => [attr, dir]);
        secondary.forEach(([attr]) => {
          method.push(QueryClause.THEN_BY, attr as string);
        });
      }
    }
    // Handle groupBy for non-aggregation queries (already handled for group prefix)
    if (
      this.groupBySelectors?.length &&
      methodPrefix !== QueryClause.GROUP_BY_PREFIX
    ) {
      const [primary, ...rest] = this.groupBySelectors;
      method.push(QueryClause.GROUP_BY, primary as string);
      rest.forEach((attr) => method.push(QueryClause.THEN_BY, attr as string));
    } else if (
      this.groupBySelectors?.length &&
      methodPrefix === QueryClause.GROUP_BY_PREFIX
    ) {
      // For group prefix, add additional group fields as ThenBy
      const rest = this.groupBySelectors.slice(1);
      rest.forEach((attr) => method.push(QueryClause.THEN_BY, attr 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.groupBySelectors && this.groupBySelectors.length) ||
      typeof this.countSelector !== "undefined" ||
      this.countDistinctSelector ||
      this.maxSelector ||
      this.minSelector ||
      this.sumSelector ||
      this.avgSelector ||
      this.distinctSelector
    );
  }

  protected hasAggregation(): boolean {
    return (
      typeof this.countSelector !== "undefined" ||
      typeof this.countDistinctSelector !== "undefined" ||
      typeof this.maxSelector !== "undefined" ||
      typeof this.minSelector !== "undefined" ||
      typeof this.sumSelector !== "undefined" ||
      typeof this.avgSelector !== "undefined" ||
      typeof this.distinctSelector !== "undefined" ||
      (this.groupBySelectors?.length || 0) > 0
    );
  }

  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`;
  }
}