Source

query/Statement.ts

import {
  Condition,
  GroupOperator,
  Operator,
  OrderDirection,
  Paginator,
  Repository,
  Sequence,
  Statement,
} from "@decaf-ts/core";
import { MangoOperator, MangoQuery, MangoSelector } from "../types";
import { Model } from "@decaf-ts/decorator-validation";
import { CouchDBAdapter } from "../adapter";
import { translateOperators } from "./translate";
import { CouchDBKeys } from "../constants";
import {
  CouchDBGroupOperator,
  CouchDBOperator,
  CouchDBQueryLimit,
} from "./constants";
import { CouchDBPaginator } from "./Paginator";
import { findPrimaryKey, InternalError } from "@decaf-ts/db-decorators";

/**
 * @description Statement builder for CouchDB Mango queries
 * @summary Provides a fluent interface for building CouchDB Mango queries with type safety
 * @template M - The model type that extends Model
 * @template R - The result type
 * @param adapter - The CouchDB adapter
 * @class CouchDBStatement
 * @example
 * // Example of using CouchDBStatement
 * const adapter = new MyCouchDBAdapter(scope);
 * const statement = new CouchDBStatement<User, User[]>(adapter);
 *
 * // Build a query
 * const users = await statement
 *   .from(User)
 *   .where(Condition.attribute<User>('age').gt(18))
 *   .orderBy('lastName', 'asc')
 *   .limit(10)
 *   .execute();
 */
export class CouchDBStatement<M extends Model, R> extends Statement<
  MangoQuery,
  M,
  R
> {
  constructor(adapter: CouchDBAdapter<any, any, any, any>) {
    super(adapter);
  }

  /**
   * @description Builds a CouchDB Mango query from the statement
   * @summary Converts the statement's conditions, selectors, and options into a CouchDB Mango query
   * @return {MangoQuery} The built Mango query
   * @throws {Error} If there are invalid query conditions
   * @mermaid
   * sequenceDiagram
   *   participant Statement
   *   participant Repository
   *   participant parseCondition
   *
   *   Statement->>Statement: build()
   *   Note over Statement: Initialize selectors
   *   Statement->>Repository: Get table name
   *   Repository-->>Statement: Return table name
   *   Statement->>Statement: Create base query
   *
   *   alt Has selectSelector
   *     Statement->>Statement: Add fields to query
   *   end
   *
   *   alt Has whereCondition
   *     Statement->>Statement: Create combined condition with table
   *     Statement->>parseCondition: Parse condition
   *     parseCondition-->>Statement: Return parsed condition
   *
   *     alt Is group operator
   *       alt Is AND operator
   *         Statement->>Statement: Flatten nested AND conditions
   *       else Is OR operator
   *         Statement->>Statement: Combine with table condition
   *       else
   *         Statement->>Statement: Throw error
   *       end
   *     else
   *       Statement->>Statement: Merge conditions with existing selector
   *     end
   *   end
   *
   *   alt Has orderBySelector
   *     Statement->>Statement: Add sort to query
   *     Statement->>Statement: Ensure field exists in selector
   *   end
   *
   *   alt Has limitSelector
   *     Statement->>Statement: Set limit
   *   else
   *     Statement->>Statement: Use default limit
   *   end
   *
   *   alt Has offsetSelector
   *     Statement->>Statement: Set skip
   *   end
   *
   *   Statement-->>Statement: Return query
   */
  protected build(): MangoQuery {
    const selectors: MangoSelector = {};
    selectors[CouchDBKeys.TABLE] = {};
    selectors[CouchDBKeys.TABLE] = Repository.table(this.fromSelector);
    const query: MangoQuery = { selector: selectors };
    if (this.selectSelector) query.fields = this.selectSelector as string[];

    if (this.whereCondition) {
      const condition: MangoSelector = this.parseCondition(
        Condition.and(
          this.whereCondition,
          Condition.attribute<M>(CouchDBKeys.TABLE as keyof M).eq(
            query.selector[CouchDBKeys.TABLE]
          )
        )
      ).selector;
      const selectorKeys = Object.keys(condition) as MangoOperator[];
      if (
        selectorKeys.length === 1 &&
        Object.values(CouchDBGroupOperator).indexOf(selectorKeys[0]) !== -1
      )
        switch (selectorKeys[0]) {
          case CouchDBGroupOperator.AND:
            condition[CouchDBGroupOperator.AND] = [
              ...Object.values(
                condition[CouchDBGroupOperator.AND] as MangoSelector
              ).reduce((accum: MangoSelector[], val: any) => {
                const keys = Object.keys(val);
                if (keys.length !== 1)
                  throw new Error(
                    "Too many keys in query selector. should be one"
                  );
                const k = keys[0];
                if (k === CouchDBGroupOperator.AND)
                  accum.push(...(val[k] as any[]));
                else accum.push(val);
                return accum;
              }, []),
            ];
            query.selector = condition;
            break;
          case CouchDBGroupOperator.OR: {
            const s: Record<any, any> = {};
            s[CouchDBGroupOperator.AND] = [
              condition,
              ...Object.entries(query.selector).map(([key, val]) => {
                const result: Record<any, any> = {};
                result[key] = val;
                return result;
              }),
            ];
            query.selector = s;
            break;
          }
          default:
            throw new Error("This should be impossible");
        }
      else {
        Object.entries(condition).forEach(([key, val]) => {
          if (query.selector[key])
            console.warn(
              `A ${key} query param is about to be overridden: ${query.selector[key]} by ${val}`
            );
          query.selector[key] = val;
        });
      }
    }

    if (this.orderBySelector) {
      query.sort = query.sort || [];
      query.selector = query.selector || ({} as MangoSelector);
      const [selector, value] = this.orderBySelector as [
        string,
        OrderDirection,
      ];
      const rec: any = {};
      rec[selector] = value;
      (query.sort as any[]).push(rec as any);
      if (!query.selector[selector]) {
        query.selector[selector] = {} as MangoSelector;
        (query.selector[selector] as MangoSelector)[CouchDBOperator.BIGGER] =
          null;
      }
    }

    if (this.limitSelector) {
      query.limit = this.limitSelector;
    } else {
      console.warn(
        `No limit selector defined. Using default couchdb limit of ${CouchDBQueryLimit}`
      );
      query.limit = CouchDBQueryLimit;
    }

    if (this.offsetSelector) query.skip = this.offsetSelector;

    return query;
  }

  /**
   * @description Creates a paginator for the statement
   * @summary Builds the query and returns a CouchDBPaginator for paginated results
   * @template R - The result type
   * @param {number} size - The page size
   * @return {Promise<Paginator<M, R, MangoQuery>>} A promise that resolves to a paginator
   * @throws {InternalError} If there's an error building the query
   */
  async paginate<R>(size: number): Promise<Paginator<M, R, MangoQuery>> {
    try {
      const query: MangoQuery = this.build();
      return new CouchDBPaginator(
        this.adapter as any,
        query,
        size,
        this.fromSelector
      );
    } catch (e: any) {
      throw new InternalError(e);
    }
  }

  /**
   * @description Processes a record from CouchDB
   * @summary Extracts the ID from a CouchDB document and reverts it to a model instance
   * @param {any} r - The raw record from CouchDB
   * @param pkAttr - The primary key attribute of the model
   * @param {"Number" | "BigInt" | undefined} sequenceType - The type of the sequence
   * @return {any} The processed record
   */
  protected processRecord(
    r: any,
    pkAttr: keyof M,
    sequenceType: "Number" | "BigInt" | undefined
  ) {
    if (r[CouchDBKeys.ID]) {
      const [, ...keyArgs] = r[CouchDBKeys.ID].split(CouchDBKeys.SEPARATOR);

      const id = keyArgs.join("_");
      return this.adapter.revert(
        r,
        this.fromSelector,
        pkAttr,
        Sequence.parseValue(sequenceType, id)
      );
    }
    return r;
  }

  /**
   * @description Executes a raw Mango query
   * @summary Sends a raw Mango query to CouchDB and processes the results
   * @template R - The result type
   * @param {MangoQuery} rawInput - The raw Mango query to execute
   * @return {Promise<R>} A promise that resolves to the query results
   */
  override async raw<R>(rawInput: MangoQuery): Promise<R> {
    const results: any[] = await this.adapter.raw(rawInput, true);

    const pkDef = findPrimaryKey(new this.fromSelector());
    const pkAttr = pkDef.id;
    const type = pkDef.props.type;

    if (!this.selectSelector)
      return results.map((r) => this.processRecord(r, pkAttr, type)) as R;
    return results as R;
  }

  /**
   * @description Parses a condition into a CouchDB Mango query selector
   * @summary Converts a Condition object into a CouchDB Mango query selector structure
   * @param {Condition<M>} condition - The condition to parse
   * @return {MangoQuery} The Mango query with the parsed condition as its selector
   * @mermaid
   * sequenceDiagram
   *   participant Statement
   *   participant translateOperators
   *   participant merge
   *
   *   Statement->>Statement: parseCondition(condition)
   *
   *   Note over Statement: Extract condition parts
   *
   *   alt Simple comparison operator
   *     Statement->>translateOperators: translateOperators(operator)
   *     translateOperators-->>Statement: Return CouchDB operator
   *     Statement->>Statement: Create selector with attribute and operator
   *   else NOT operator
   *     Statement->>Statement: parseCondition(attr1)
   *     Statement->>translateOperators: translateOperators(Operator.NOT)
   *     translateOperators-->>Statement: Return CouchDB NOT operator
   *     Statement->>Statement: Create negated selector
   *   else AND/OR operator
   *     Statement->>Statement: parseCondition(attr1)
   *     Statement->>Statement: parseCondition(comparison)
   *     Statement->>translateOperators: translateOperators(operator)
   *     translateOperators-->>Statement: Return CouchDB group operator
   *     Statement->>merge: merge(operator, op1, op2)
   *     merge-->>Statement: Return merged selector
   *   end
   *
   *   Statement-->>Statement: Return query with selector
   */
  protected parseCondition(condition: Condition<M>): MangoQuery {
    /**
     * @description Merges two selectors with a logical operator
     * @summary Helper function to combine two selectors with a logical operator
     * @param {MangoOperator} op - The operator to use for merging
     * @param {MangoSelector} obj1 - The first selector
     * @param {MangoSelector} obj2 - The second selector
     * @return {MangoQuery} The merged query
     */
    function merge(
      op: MangoOperator,
      obj1: MangoSelector,
      obj2: MangoSelector
    ): MangoQuery {
      const result: MangoQuery = { selector: {} as MangoSelector };
      result.selector[op] = [obj1, obj2];
      return result;
    }

    const { attr1, operator, comparison } = condition as unknown as {
      attr1: string | Condition<M>;
      operator: Operator | GroupOperator;
      comparison: any;
    };

    let op: MangoSelector = {} as MangoSelector;
    if (
      [GroupOperator.AND, GroupOperator.OR, Operator.NOT].indexOf(
        operator as GroupOperator
      ) === -1
    ) {
      op[attr1 as string] = {} as MangoSelector;
      (op[attr1 as string] as MangoSelector)[translateOperators(operator)] =
        comparison;
    } else if (operator === Operator.NOT) {
      op = this.parseCondition(attr1 as Condition<M>).selector as MangoSelector;
      op[translateOperators(Operator.NOT)] = {} as MangoSelector;
      (op[translateOperators(Operator.NOT)] as MangoSelector)[
        (attr1 as unknown as { attr1: string }).attr1
      ] = comparison;
    } else {
      const op1: any = this.parseCondition(attr1 as Condition<M>).selector;
      const op2: any = this.parseCondition(comparison as Condition<M>).selector;
      op = merge(translateOperators(operator), op1, op2).selector;
    }

    return { selector: op };
  }
}