Source

query/MethodQueryBuilder.ts

import { Condition } from "../query/Condition";
import { OrderBySelector } from "../query/selectors";
import {
  FilterDescriptor,
  OrderLimitOffsetExtract,
  QueryAction,
  QueryAssist,
  QueryClause,
} from "./types";
import { OperatorsMap } from "./utils";
import { Context } from "@decaf-ts/db-decorators";
import { LoggedClass, Logger, Logging } from "@decaf-ts/logging";
import { OrderDirection } from "../repository/constants";
import { QueryError } from "./errors";

const lowerFirst = (str: string): string =>
  str.charAt(0).toLowerCase() + str.slice(1);

export type QueryActionPrefix = {
  action: QueryAction;
  prefix: string;
};

/**
 * @description
 * Utility class to build query objects from repository method names.
 *
 * @summary
 * The `MethodQueryBuilder` class parses method names that follow a specific naming convention
 * (e.g., `findByNameAndAgeOrderByCountryAsc`) and converts them into structured query objects
 * (`QueryAssist`). It extracts clauses such as `select`, `where`, `groupBy`, `orderBy`, `limit`,
 * and `offset`, ensuring that developers can declare repository queries using expressive method names.
 *
 * @param methodName {string} - The repository method name to parse and convert into a query.
 * @param values {any[]} - The values corresponding to method parameters used for query conditions.
 *
 * @return {QueryAssist} A structured query object describing the parsed action, select, where,
 * groupBy, orderBy, limit, and offset clauses.
 *
 * @class
 *
 * @example
 * ```ts
 * const query = MethodQueryBuilder.build(
 *   "findByNameAndAgeOrderByCountryAsc",
 *   "John",
 *   25,
 *   [["country", "ASC"]]
 * );
 *
 * console.log(query);
 * // {
 * //   action: "find",
 * //   select: undefined,
 * //   where: { ... },
 * //   groupBy: undefined,
 * //   orderBy: [["country", "ASC"]],
 * //   limit: undefined,
 * //   offset: undefined
 * // }
 * ```
 *
 * @mermaid
 * sequenceDiagram
 *   participant Repo as Repository Method
 *   participant MQB as MethodQueryBuilder
 *   participant Query as QueryAssist
 *
 *   Repo->>MQB: build(methodName, ...values)
 *   MQB->>MQB: extractCore(methodName)
 *   MQB->>MQB: extractSelect(methodName)
 *   MQB->>MQB: extractGroupBy(methodName)
 *   MQB->>MQB: buildWhere(core, values)
 *   MQB->>MQB: extractOrderLimitOffset(core, values)
 *   MQB->>Query: return structured QueryAssist object
 */
export class MethodQueryBuilder extends LoggedClass {
  private static _logger: Logger;

  protected static get log(): Logger {
    if (!this._logger) this._logger = Logging.for(MethodQueryBuilder.name);
    return this._logger;
  }

  /**
   * @description
   * Map of query prefixes to their corresponding actions.
   */
  private static readonly prefixMap: Record<string, QueryAction> = {
    [QueryClause.FIND_BY]: "find",
    [QueryClause.PAGE_BY]: "page",
    [QueryClause.COUNT_BY]: "count",
    [QueryClause.SUM_BY]: "sum",
    [QueryClause.AVG_BY]: "avg",
    [QueryClause.MIN_BY]: "min",
    [QueryClause.MAX_BY]: "max",
    [QueryClause.DISTINCT_BY]: "distinct",
    [QueryClause.GROUP_BY_PREFIX]: "group",
  };

  /**
   * @description
   * Determines the action and prefix length from a method name.
   *
   * @param methodName {string} - The repository method name.
   * @return {QueryActionPrefix} | undefined} The action and prefix if found.
   */
  private static getActionFromMethodName(
    methodName: string
  ): QueryActionPrefix | undefined {
    for (const [prefix, action] of Object.entries(this.prefixMap)) {
      if (methodName.startsWith(prefix)) {
        return { action, prefix };
      }
    }
    return undefined;
  }

  /**
   * @description
   * Builds a `QueryAssist` object by parsing a repository method name and values.
   *
   * @summary
   * The method validates the method name, extracts clauses (core, select, groupBy, where,
   * orderBy, limit, and offset), and assembles them into a structured query object
   * that can be executed against a data source.
   *
   * @param methodName {string} - The repository method name that encodes query information.
   * @param values {any[]} - The values corresponding to conditions and extra clauses.
   *
   * @return {QueryAssist} A structured query object representing the parsed query.
   */
  static build(methodName: string, ...values: any[]): QueryAssist {
    const actionInfo = this.getActionFromMethodName(methodName);
    if (!actionInfo) {
      throw new Error(`Unsupported method ${methodName}`);
    }

    const { action, prefix } = actionInfo;

    // For aggregation methods (count, sum, avg, min, max, distinct), extract the selector field
    let selector: string | undefined;
    if (
      ["count", "sum", "avg", "min", "max", "distinct", "group"].includes(
        action
      )
    ) {
      selector = this.extractAggregationSelector(methodName, prefix);
    }

    const core = this.extractCore(methodName, prefix);
    const select = this.extractSelect(methodName);
    const groupBy = this.extractGroupBy(methodName);
    const where = this.buildWhere(core, values);
    const { orderBy, limit, offset } = this.extractOrderLimitOffset(
      methodName,
      values,
      core
    );

    return {
      action,
      select,
      selector,
      where,
      groupBy,
      orderBy,
      limit,
      offset,
    };
  }

  /**
   * @description
   * Extracts the aggregation selector field from method names like countByAge, sumByPrice.
   *
   * @param methodName {string} - The method name.
   * @param prefix {string} - The prefix to remove.
   * @return {string | undefined} The selector field name.
   */
  private static extractAggregationSelector(
    methodName: string,
    prefix: string
  ): string | undefined {
    const afterPrefix = methodName.substring(prefix.length);
    // Find where the selector ends (at next clause keyword, operator, or end)
    // Include ThenBy for groupBy prefix
    const regex = /(And|Or|GroupBy|OrderBy|Select|Limit|Offset|ThenBy)/;
    const match = afterPrefix.match(regex);
    const selectorPart = match
      ? afterPrefix.substring(0, match.index)
      : afterPrefix;
    return selectorPart ? lowerFirst(selectorPart) : undefined;
  }

  /**
   * @description
   * Extracts the core part of the method name after the prefix and before any special clauses.
   *
   * @summary
   * Removes prefixes and detects delimiters (`Then`, `OrderBy`, `GroupBy`, `Limit`, `Offset`)
   * to isolate the main conditional part of the query.
   *
   * @param methodName {string} - The method name to parse.
   * @param prefix {string} - The prefix to remove (e.g., "findBy", "countBy").
   *
   * @return {string} The extracted core string used for building conditions.
   */
  private static extractCore(
    methodName: string,
    prefix: string = QueryClause.FIND_BY
  ): string {
    const afterPrefix = methodName.substring(prefix.length);

    // For aggregation methods (not findBy or pageBy), we need to skip the selector field
    const isAggregationPrefix =
      prefix !== QueryClause.FIND_BY && prefix !== QueryClause.PAGE_BY;

    if (isAggregationPrefix) {
      // For aggregation methods, we need to find where actual conditions start
      // Conditions are indicated by And|Or AFTER the selector field
      // For "countByAgeAndNameEquals", conditions start at "AndNameEquals"
      // For "sumByPriceGroupByCategory", there are no conditions

      // First, identify the selector field boundary
      // The selector ends at: And, Or, GroupBy, OrderBy, ThenBy, Select, Limit, Offset
      const selectorEndMatch = afterPrefix.match(
        /(And|Or|GroupBy|OrderBy|ThenBy|Select|Limit|Offset)/
      );

      if (!selectorEndMatch) {
        // No delimiter found, entire suffix is the selector, no conditions
        return "";
      }

      // Check if the first delimiter is And or Or (indicating a condition)
      if (selectorEndMatch[0] === "And" || selectorEndMatch[0] === "Or") {
        // Extract everything AFTER the And/Or (skip the And/Or itself for the first condition)
        const afterAndOr = afterPrefix.substring(
          selectorEndMatch.index! + selectorEndMatch[0].length
        );

        // Now apply standard delimiter extraction
        const delimiterMatch = afterAndOr.match(
          /(Then[A-Z]|OrderBy|GroupBy|Limit|Offset|Select|ThenBy)/
        );
        return delimiterMatch
          ? afterAndOr.substring(0, delimiterMatch.index)
          : afterAndOr;
      }

      // First delimiter is not And/Or, so there are no conditions
      return "";
    }

    // For findBy and pageBy, extract core normally
    const regex = /(Then[A-Z]|OrderBy|GroupBy|Limit|Offset|Select)/;
    const match = afterPrefix.match(regex);
    const corePart = match
      ? afterPrefix.substring(0, match.index)
      : afterPrefix;

    return corePart;
  }

  static getFieldsFromMethodName(methodName: string): Array<string> {
    const actionInfo = this.getActionFromMethodName(methodName);
    const prefix = actionInfo?.prefix || QueryClause.FIND_BY;
    const core = this.extractCore(methodName, prefix);
    if (!core) return [];
    const parts = core.split(/OrderBy|GroupBy/)[0] || "";
    const conditions = parts.split(/And|Or/);
    return conditions
      .filter((token) => token.length > 0)
      .map((token) => {
        const { operator, field } = this.parseFieldAndOperator(token);
        return field + (operator ?? "");
      });
  }

  /**
   * @description
   * Extracts the select clause from a method name.
   *
   * @summary
   * Detects the `Select` keyword in the method name, isolates the fields following it,
   * and returns them as an array of lowercase-first strings.
   *
   * @param methodName {string} - The method name to parse.
   *
   * @return {string[] | undefined} An array of selected fields or `undefined` if no select clause exists.
   */
  private static extractSelect(methodName: string): string[] | undefined {
    const selectIndex = methodName.indexOf(QueryClause.SELECT);
    if (selectIndex === -1) return undefined;

    const afterSelect = methodName.substring(
      selectIndex + QueryClause.SELECT.length
    );

    // Search for next Then, GroupBy, OrderBy...
    const match = afterSelect.match(/(Then[A-Z]|OrderBy|GroupBy|Limit|Offset)/);

    const selectPart = match
      ? afterSelect.substring(0, match.index)
      : afterSelect;

    return selectPart.split(QueryClause.AND).map(lowerFirst).filter(Boolean);
  }

  /**
   * @description
   * Extracts the group by clause from a method name.
   *
   * @summary
   * Detects the `GroupBy` keyword in the method name, isolates the fields following it,
   * and returns them as an array of lowercase-first strings.
   *
   * @param methodName {string} - The method name to parse.
   *
   * @return {string[] | undefined} An array of group by fields or `undefined` if no group by clause exists.
   */
  private static extractGroupBy(methodName: string): string[] | undefined {
    // First check for the standard "GroupBy" clause (e.g., findByActiveGroupByCountry)
    const groupByIndex = methodName.indexOf(QueryClause.GROUP_BY);
    if (groupByIndex !== -1) {
      const after = methodName.substring(
        groupByIndex + QueryClause.GROUP_BY.length
      );
      const groupByPart = after.split(QueryClause.ORDER_BY)[0];
      return groupByPart
        .split(QueryClause.THEN_BY)
        .map(lowerFirst)
        .filter(Boolean);
    }

    // For "groupBy" prefix (e.g., groupByCategoryThenByRegion),
    // extract ThenBy fields after the selector
    const actionInfo = this.getActionFromMethodName(methodName);
    if (actionInfo?.action === "group") {
      const afterPrefix = methodName.substring(actionInfo.prefix.length);
      const thenByIndex = afterPrefix.indexOf(QueryClause.THEN_BY);
      if (thenByIndex === -1) return undefined;

      const afterThenBy = afterPrefix.substring(
        thenByIndex + QueryClause.THEN_BY.length
      );
      // Split by ThenBy to get multiple group fields
      const parts = afterThenBy.split(QueryClause.THEN_BY);
      // Also stop at OrderBy, Limit, Offset
      const result: string[] = [];
      for (const part of parts) {
        const match = part.match(/(OrderBy|Limit|Offset|Select)/);
        const field = match ? part.substring(0, match.index) : part;
        if (field) result.push(lowerFirst(field));
      }
      return result.length > 0 ? result : undefined;
    }

    return undefined;
  }

  // private static extractOrderBy(
  //   methodName: string
  // ): OrderBySelector<any>[] | undefined {
  //   const orderByIndex = methodName.indexOf(QueryClause.ORDER_BY);
  //   if (orderByIndex === -1) return undefined;
  //
  //   const after = methodName.substring(
  //     orderByIndex + QueryClause.ORDER_BY.length
  //   );
  //   const orderParts = after.split("ThenBy");
  //
  //   return orderParts.map((part) => {
  //     const match = part.match(/(.*?)(Asc|Desc|Dsc)$/);
  //     if (!match) throw new Error(`Invalid OrderBy part: ${part}`);
  //     const [, field, dir] = match;
  //     return [
  //       lowerFirst(field),
  //       dir.toLowerCase() === "dsc"
  //         ? OrderDirection.DSC
  //         : (dir.toLowerCase() as OrderDirection),
  //     ];
  //   });
  // }

  /**
   * @description
   * Builds the `where` condition object based on the parsed core string and parameter values.
   *
   * @summary
   * Splits the core string by logical operators (`And`, `Or`), parses each token into a field
   * and operator, and combines them into a `Condition` object using the provided values.
   *
   * @param core {string} - The extracted core string from the method name.
   * @param values {any[]} - The values corresponding to the conditions.
   *
   * @return {Condition<any>} A structured condition object representing the query's where clause.
   */
  private static buildWhere(
    core: string,
    values: any[]
  ): Condition<any> | undefined {
    // Empty core means no where conditions
    if (!core) return undefined;

    const parts = core.split(/OrderBy|GroupBy/)[0] || "";
    if (!parts) return undefined;

    const conditions = parts.split(/And|Or/).filter((c) => c.length > 0);
    if (conditions.length === 0) return undefined;

    const operators = core.match(/And|Or/g) || [];

    let where: Condition<any> | undefined;

    conditions.forEach((token, idx) => {
      const { field, operator } = this.parseFieldAndOperator(token);
      const parser = operator ? OperatorsMap[operator] : OperatorsMap.Equals;
      if (!parser) throw new Error(`Unsupported operator ${operator}`);

      const conditionValue = values[idx];
      if (typeof conditionValue === "undefined") {
        throw new Error(`Invalid value for field ${field}`);
      }

      const condition = parser(field, conditionValue);
      where =
        idx === 0
          ? condition
          : operators[idx - 1] === QueryClause.AND
            ? where!.and(condition)
            : where!.or(condition);
    });

    return where;
  }

  /**
   * @description
   * Parses a field name and operator from a string token.
   *
   * @summary
   * Identifies the operator suffix (if present) and returns a descriptor containing the field
   * name in lowercase-first format along with the operator.
   *
   * @param str {string} - The token string to parse.
   *
   * @return {FilterDescriptor} An object containing the field name and operator.
   */
  private static parseFieldAndOperator(str: string): FilterDescriptor {
    for (const operator of Object.keys(OperatorsMap)) {
      if (str.endsWith(operator)) {
        const field = str.slice(0, -operator.length);
        return { field: lowerFirst(field), operator };
      }
    }
    return { field: lowerFirst(str) };
  }

  private static extractOrderByField(methodName: string): string | undefined {
    // new Regex(`${QueryClause.ORDER_BY}`);
    const match = methodName.match(/OrderBy(.+)$/);
    if (!match) return undefined;
    const field = match[1];
    return field.charAt(0).toLowerCase() + field.slice(1);
  }

  private static getProperlyOrderByOrThrow(
    field: string | undefined,
    direction: OrderDirection | undefined
  ): Array<OrderBySelector<any>> | undefined {
    const log = MethodQueryBuilder.log.for(this.getProperlyOrderByOrThrow);
    // Both absent → ignore OrderBy
    if (!direction && !field) return;

    if (direction && !field)
      throw new QueryError(
        `Expected OrderBy clause, but no sortable field was found in method name.`
      );

    // Field present, but direction is undefined → ignore OrderBy
    if (!direction && field) {
      log.debug("Ignoring OrderBy clause because direction is undefined.");
      return;
    }

    // Both present → validate direction
    const allowedDirections = Object.values(OrderDirection);
    if (!allowedDirections.includes(direction as any)) {
      throw new QueryError(
        `Invalid OrderBy direction ${direction}. Expected one of: ${Object.values(OrderDirection).join(", ")}.`
      );
    }

    return [[field as any, direction as OrderDirection]];
  }

  /**
   * @description
   * Extracts `orderBy`, `limit`, and `offset` clauses from method arguments.
   *
   * @summary
   * Determines the number of condition arguments, then checks the remaining arguments
   * to resolve sorting, limiting, and pagination.
   *
   * @param methodName {string} - The method name.
   * @param values {any[]} - The values corresponding to method arguments, including conditions and extras.
   * @param core {string} - The pre-extracted core string.
   *
   * @return {OrderLimitOffsetExtract} An object containing orderBy, limit, and offset values if present.
   */
  private static extractOrderLimitOffset(
    methodName: string,
    values: any[],
    core?: string
  ): OrderLimitOffsetExtract {
    const coreString = core ?? this.extractCore(methodName);
    const conditionCount = coreString
      ? coreString.split(/And|Or/).filter((s) => s.length > 0).length
      : 0;
    const extraArgs: any[] = values.slice(conditionCount) ?? [];

    let orderBy: Array<OrderBySelector<any>> | undefined;
    let limit: number | undefined;
    let offset: number | undefined;

    if (extraArgs.at(-1) instanceof Context) extraArgs.pop();

    if (extraArgs.length >= 1) {
      const direction = extraArgs[0];
      const field = this.extractOrderByField(methodName);
      orderBy = this.getProperlyOrderByOrThrow(field, direction);
    }

    if (extraArgs.length >= 2 && typeof extraArgs[1] === "number")
      limit = extraArgs[1];

    if (extraArgs.length >= 3 && typeof extraArgs[2] === "number")
      offset = extraArgs[2];

    return { orderBy, limit, offset };
  }
}