baublet/w8mngr

View on GitHub
api/helpers/buildConnectionResolver/buildConnectionResolver.ts

Summary

Maintainability
D
2 days
Test Coverage
import { assertIsError } from "../../../shared";
import { Resolvable } from "../../../shared/types";
import { QueryBuilder } from "../../config/db";
import { log } from "../../config/log";
import { isBefore } from "./isBefore";
import { validateArguments } from "./validateArguments";

export type Connection<TEntity, TNode = TEntity> = Resolvable<{
  pageInfo: {
    totalCount: number;
    hasPreviousPage: boolean;
    hasNextPage: boolean;
  };
  edges: {
    cursor: string;
    entity: TEntity;
    node: TNode;
  }[];
  _resultsQueryText: string;
}>;

export type Cursor = {
  id: string;
  cursorData: Record<string, ["asc" | "desc", any]>;
};

function flipDirection(dir: "asc" | "desc"): "asc" | "desc" {
  if (dir === "asc") return "desc";
  return "asc";
}

function defaultEntityTransformer(entity: any) {
  return entity;
}

export async function buildConnectionResolver<TEntity, TNode = TEntity>(
  query: QueryBuilder,
  args: {
    before?: string | null;
    last?: number | null;
    first?: number | null;
    after?: string | null;
    sort?: Record<string | keyof TEntity, "asc" | "desc">;
    idProp?: string;
  },
  nodeTransformer: (
    entity: TEntity
  ) => Promise<TNode> = defaultEntityTransformer,
  additionalRootResolvers?: Record<string, any>
): Promise<Connection<TEntity, TNode> | Error> {
  try {
    validateArguments(args);

    const {
      first,
      after,
      before,
      last,
      sort = { id: "asc" } as Record<string | keyof TEntity, "asc" | "desc">,
      idProp = "id",
    } = args;
    const isBeforeQuery = isBefore(args);
    const totalCountQuery = query.clone().clearSelect().count("id AS count");
    const resultSetQuery = query.clone();
    const firstResultQuery = query
      .clone()
      .clearSelect()
      .select(idProp)
      .limit(1);
    const lastResultQuery = query.clone().clearSelect().select(idProp).limit(1);

    let cursor: Cursor | undefined;

    if (isBeforeQuery && before) {
      cursor = deserializeCursor(before);
    }

    if (!isBeforeQuery && after) {
      cursor = deserializeCursor(after);
    }

    if (cursor) {
      for (const [column, [sortDirection, value]] of Object.entries(
        cursor.cursorData
      )) {
        if (isBeforeQuery) {
          resultSetQuery.where(
            column,
            sortDirection === "desc" ? ">" : "<",
            value
          );
        } else {
          resultSetQuery.where(
            column,
            sortDirection === "desc" ? "<" : ">",
            value
          );
        }
      }
    }

    for (const [key, direction] of Object.entries(sort)) {
      if (isBeforeQuery) {
        resultSetQuery.orderBy(key, flipDirection(direction));
        firstResultQuery.orderBy(key, flipDirection(direction));
        lastResultQuery.orderBy(key, direction);
      } else {
        resultSetQuery.orderBy(key, direction);
        firstResultQuery.orderBy(key, direction);
        lastResultQuery.orderBy(key, flipDirection(direction));
      }
    }

    if (isBeforeQuery && last) {
      resultSetQuery.limit(last);
    }
    if (!isBeforeQuery && first) {
      resultSetQuery.limit(first);
    }

    let totalCount: Promise<number>;
    const totalCountFn = () => {
      if (!totalCount) {
        totalCount = new Promise<number>(async (resolve) => {
          const count = await totalCountQuery;
          resolve(orZero(count[0].count));
        });
      }
      return totalCount;
    };

    let edges: Promise<
      {
        cursor: string;
        entity: TEntity;
        node: TNode;
      }[]
    >;

    const edgesFn = () => {
      if (!edges) {
        edges = new Promise<
          {
            cursor: string;
            entity: TEntity;
            node: TNode;
          }[]
        >(async (resolve) => {
          const edges: {
            cursor: string;
            entity: TEntity;
            node: TNode;
          }[] = [];

          const results = await resultSetQuery;
          for (const result of results) {
            const edge = {
              cursor: serializeCursor(result, idProp, sort),
              entity: result,
              node: await nodeTransformer(result),
            };
            if (isBeforeQuery) {
              edges.unshift(edge);
            } else {
              edges.push(edge);
            }
          }

          log("info", "Result set query information", {
            query: resultSetQuery.toString(),
          });

          resolve(edges);
        });
      }
      return edges;
    };

    let hasNextPage: Promise<boolean>;
    const hasNextPageFn = () => {
      if (!hasNextPage) {
        hasNextPage = new Promise<boolean>(async (resolve) => {
          const resolvedEdges = await edgesFn();

          const lastSubsetResultEdge:
            | undefined
            | { node: Record<string, any> } =
            resolvedEdges[resolvedEdges.length - 1];
          const lastSubsetResult: undefined | Record<string, any> =
            lastSubsetResultEdge?.node;

          if (!lastSubsetResult) {
            return resolve(false);
          }

          // Get the last ID of the full result set and compare it to the first
          // result of subset. If they don't match, there's more before this!
          const lastResults = isBeforeQuery
            ? await firstResultQuery
            : await lastResultQuery;
          const lastResult = lastResults[0];

          if (!lastResult) {
            return resolve(false);
          }

          const hasNextPage = lastResult[idProp] !== lastSubsetResult[idProp];

          resolve(hasNextPage);
        });
      }
      return hasNextPage;
    };

    let hasPreviousPage: Promise<boolean>;
    const hasPreviousPageFn = () => {
      if (!hasPreviousPage) {
        hasPreviousPage = new Promise<boolean>(async (resolve) => {
          const resolvedEdges = await edgesFn();

          const firstSubsetResultEdge:
            | undefined
            | { node: Record<string, any> } = resolvedEdges[0];
          const firstSubsetResult: undefined | Record<string, any> =
            firstSubsetResultEdge?.node;

          if (!firstSubsetResult) {
            return resolve(false);
          }

          // Get the first ID of the full result set and compare it to the first
          // result of subset. If they don't match, there's more before this!
          const firstResults = isBeforeQuery
            ? await lastResultQuery
            : await firstResultQuery;
          const firstResult = firstResults[0];

          if (!firstResult) {
            return resolve(false);
          }

          const hasPreviousPage =
            firstResult[idProp] !== firstSubsetResult[idProp];
          resolve(hasPreviousPage);
        });
      }
      return hasPreviousPage;
    };

    const additionalRootResolversWithDefault = additionalRootResolvers || {};

    return {
      ...additionalRootResolversWithDefault,
      pageInfo: {
        totalCount: totalCountFn,
        hasNextPage: hasNextPageFn,
        hasPreviousPage: hasPreviousPageFn,
      },
      edges: edgesFn,
      _resultsQueryText: resultSetQuery.toQuery(),
    };
  } catch (error) {
    assertIsError(error);
    return error;
  }
}

function deserializeCursor(cursorString: string): Cursor {
  try {
    const cursorResults: Cursor = JSON.parse(
      Buffer.from(cursorString, "base64").toString("utf-8")
    );
    return cursorResults;
  } catch (e) {
    throw new InvalidCursorError(cursorString);
  }
}

function serializeCursor(
  entity: Record<string, any>,
  idProp: string,
  sortFields: Record<string, "asc" | "desc">
): string {
  const cursor: Cursor = { id: entity[idProp], cursorData: {} };
  for (const [column, direction] of Object.entries(sortFields)) {
    cursor.cursorData[column] = [direction, entity[column]];
  }
  return Buffer.from(JSON.stringify(cursor)).toString("base64");
}

class InvalidCursorError extends Error {
  constructor(cursor: string, message?: string) {
    super(
      `Invalid cursor! Expect cursor to deserialize into an object. ${cursor}${
        message ?? `\n\n${message}`
      }`
    );
  }
}

function orZero(value: any): number {
  if (typeof value === "number") return value;
  if (typeof value === "string") {
    const num = parseInt(value, 10);
    if (num < 0) {
      return 0;
    }
    if (isNaN(num)) {
      return 0;
    }
    if (num === Infinity) {
      return 0;
    }
    return num;
  }
  return 0;
}