fratzinger/feathers-utils

View on GitHub
src/utils/mergeQuery/utils.ts

Summary

Maintainability
F
1 wk
Test Coverage
B
87%
import { Forbidden } from "@feathersjs/errors";
import _get from "lodash/get.js";
import _has from "lodash/has.js";

import _set from "lodash/set.js";
import _uniqWith from "lodash/uniqWith.js";
import type { Path } from "../../typesInternal";
import { mergeArrays } from "./mergeArrays";
import type { Handle, MergeQueryOptions } from "./types";
import { deepEqual as _isEqual } from "fast-equals";
import type { Query } from "@feathersjs/feathers";
import { hasOwnProperty, isEmpty } from "../_utils.internal";

export function handleArray(
  target: Record<string, unknown>,
  source: Record<string, unknown>,
  key: Path,
  options: MergeQueryOptions,
): void {
  const targetVal = _get(target, key);
  const sourceVal = _get(source, key);
  if (!sourceVal && !targetVal) {
    return;
  }
  const handle: Handle = _get(
    options,
    ["handle", ...key],
    options.defaultHandle,
  );
  const arr = mergeArrays(
    targetVal,
    sourceVal,
    handle,
    key,
    options.actionOnEmptyIntersect,
  );
  _set(target, key, arr);
}

export function handleCircular(
  target: Record<string, unknown>,
  source: Record<string, unknown>,
  prependKey: Path,
  options: MergeQueryOptions,
): void {
  if (target?.$or) {
    target.$or = cleanOr(target.$or as Record<string, unknown>[]);
    if (!target.$or) {
      delete target.$or;
    }
  }
  if (source?.$or) {
    source.$or = cleanOr(source.$or as Record<string, unknown>[]);
    if (!source.$or) {
      delete source.$or;
    }
  }

  if (target?.$and) {
    target.$and = cleanAnd(target.$and as Record<string, unknown>[]);
    if (!target.$and) {
      delete target.$and;
    }
  }

  if (source?.$and) {
    source.$and = cleanAnd(source.$and as Record<string, unknown>[]);
    if (!source.$and) {
      delete source.$and;
    }
  }

  if (!_has(source, prependKey)) {
    return;
  }

  if (!_has(target, prependKey)) {
    _set(target, prependKey, _get(source, prependKey));
    return;
  }

  const { defaultHandle, actionOnEmptyIntersect } = options;

  if (defaultHandle === "target") {
    return;
  }

  const getTargetVal = () => {
    return prependKey.length > 0 ? _get(target, prependKey) : target;
  };

  const getSourceVal = () => {
    return prependKey.length > 0 ? _get(source, prependKey) : source;
  };

  const targetVal = getTargetVal();
  const sourceVal = getSourceVal();

  if (_isEqual(targetVal, sourceVal)) {
    return;
  }

  if (defaultHandle === "source") {
    _set(target, prependKey, sourceVal);
    return;
  }

  if (targetVal === null || sourceVal === null) {
    _set(target, prependKey, sourceVal);
    return;
  }

  const typeOfTargetVal = typeof targetVal;

  if (["boolean"].includes(typeOfTargetVal)) {
    if (defaultHandle === "intersect") {
      actionOnEmptyIntersect(target, source, prependKey);
    }
    _set(target, prependKey, sourceVal);
    return;
  }

  const typeOfSourceVal = typeof sourceVal;

  const isTargetSimple = ["string", "number"].includes(typeOfTargetVal);
  const isSourceSimple = ["string", "number"].includes(typeOfSourceVal);

  if (isTargetSimple || isSourceSimple) {
    if (isTargetSimple && isSourceSimple) {
      if (defaultHandle === "combine") {
        _set(target, prependKey, { $in: [...new Set([targetVal, sourceVal])] });
        return;
      } else if (defaultHandle === "intersect") {
        actionOnEmptyIntersect(target, source, prependKey);
      } else {
        throw new Error("should not reach here");
      }
    } else if (
      hasOwnProperty(targetVal, "$in") ||
      hasOwnProperty(sourceVal, "$in")
    ) {
      const targetHasIn = hasOwnProperty(targetVal, "$in");

      const $in = targetHasIn ? targetVal["$in"] : sourceVal["$in"];
      const otherVal = isTargetSimple ? targetVal : sourceVal;
      if ($in.length === 1 && _isEqual($in[0], otherVal)) {
        _set(target, prependKey, otherVal);
        return;
      } else if (defaultHandle === "combine") {
        if (!$in.some((x: unknown) => _isEqual(x, otherVal))) {
          $in.push(otherVal);
        }
        _set(target, `${prependKey}.$in`, $in);
        return;
      } else if (defaultHandle === "intersect") {
        if ($in.some((x: unknown) => _isEqual(x, otherVal))) {
          _set(target, prependKey, otherVal);
        } else {
          actionOnEmptyIntersect(target, source, prependKey);
        }
        return;
      }
      return;
    }
  }

  const isTargetArray = Array.isArray(targetVal);
  const isSourceArray = Array.isArray(sourceVal);

  if (isTargetArray && isSourceArray) {
    const key = prependKey[prependKey.length - 1];
    if (key === "$or") {
      if (defaultHandle === "combine") {
        const newVals = sourceVal.filter(
          (x: unknown) => !targetVal.some((y: unknown) => _isEqual(x, y)),
        );
        targetVal.push(...newVals);
      } else if (defaultHandle === "intersect") {
        // combine into "$and"
        const targetParent = getParentProp(target, prependKey);
        const sourceParent = getParentProp(source, prependKey);
        targetParent.$and = targetParent.$and || [];

        targetParent.$and.push({ $or: targetVal }, { $or: sourceVal });

        targetParent.$and = cleanAnd(targetParent.$and);
        if (!targetParent.$and) {
          delete targetParent.$and;
        }
        delete targetParent.$or;
        delete sourceParent.$or;
        handleCircular(target, source, [...prependKey, "$and"], options);
        return;
      }
      return;
    } else if (key === "$and") {
      if (defaultHandle === "combine") {
        // combine into "$or"
        const targetParent = getParentProp(target, prependKey);
        const sourceParent = getParentProp(source, prependKey);

        targetParent.$or = targetParent.$or || [];
        targetParent.$or.push({ $and: targetVal }, { $and: sourceVal });
        targetParent.$or = cleanOr(targetParent.$or);
        if (!targetParent.$or) {
          delete targetParent.$or;
        }
        delete targetParent.$and;
        delete sourceParent.$and;
        handleCircular(target, source, [...prependKey, "$or"], options);
        return;
      } else if (defaultHandle === "intersect") {
        const newVals = sourceVal.filter(
          (x: unknown) => !targetVal.some((y: unknown) => _isEqual(x, y)),
        );
        targetVal.push(...newVals);
        return;
      }
    } else if (key === "$in") {
      if (defaultHandle === "combine") {
        let $in: unknown[] = targetVal.concat(sourceVal);
        $in = [...new Set($in)];
        _set(target, prependKey, $in);
        return;
      } else if (defaultHandle === "intersect") {
        const $in = targetVal.filter((x: unknown) =>
          sourceVal.some((y: unknown) => _isEqual(x, y)),
        );
        if ($in.length === 0) {
          actionOnEmptyIntersect(target, source, prependKey);
        } else if ($in.length === 1) {
          _set(target, prependKey.slice(0, -1), $in[0]);
          return;
        } else {
          _set(target, prependKey, $in);
        }
      }
      return;
    }

    _set(target, prependKey, sourceVal);
    return;
  }

  if (typeOfTargetVal !== "object" || typeOfSourceVal !== "object") {
    _set(target, prependKey, sourceVal);
    return;
  }

  // both are objects
  const sourceKeys = Object.keys(sourceVal);

  for (let i = 0, n = sourceKeys.length; i < n; i++) {
    const key = sourceKeys[i];
    handleCircular(target, source, [...prependKey, key], options);
  }
}

export function makeDefaultOptions(
  options?: Partial<MergeQueryOptions>,
): MergeQueryOptions {
  options ??= {} as MergeQueryOptions;
  options.defaultHandle ??= "combine";
  options.useLogicalConjunction ??= false;
  options.actionOnEmptyIntersect ??= () => {
    throw new Forbidden("You're not allowed to make this request");
  };
  options.handle = options.handle || {};
  if (options.defaultHandle === "intersect") {
    options.handle.$select = options.handle.$select || "intersectOrFull";
  }
  return options as MergeQueryOptions;
}

export function moveProperty(
  from: Record<string, any>,
  to: Record<string, any>,
  ...keys: string[]
): void {
  keys.forEach((key) => {
    if (!hasOwnProperty(from, key)) {
      return;
    }
    to[key] = from[key];
    delete from[key];
  });
}

export function getParentProp(target: Record<string, unknown>, path: Path) {
  if (path.length <= 1) {
    return target;
  }
  const pathOneUp = path.slice(0, -1);
  return _get(target, pathOneUp);
}

export function cleanOr(
  target: Record<string, unknown>[],
): Record<string, unknown>[] | undefined {
  if (!target || !Array.isArray(target) || target.length <= 0) {
    return target;
  }

  if (target.some((x) => isEmpty(x))) {
    return undefined;
  } else {
    return arrayWithoutDuplicates(target);
  }
}

export function cleanAnd(
  target: Record<string, unknown>[],
): Record<string, unknown>[] | undefined {
  if (!target || !Array.isArray(target) || target.length <= 0) {
    return target;
  }

  if (target.every((x) => isEmpty(x))) {
    return undefined;
  } else {
    target = target.filter((x) => !isEmpty(x));
    return arrayWithoutDuplicates(target);
  }
}

export function arrayWithoutDuplicates<T>(target: T[]): T[] {
  if (!target || !Array.isArray(target)) {
    return target;
  }

  return _uniqWith(target, _isEqual);
}

/**
 * Checks if one query is a superset of the target query
 * @param target The target query
 * @param source The source query
 * @returns The query that is the superset of the other query, returns undefined otherwise
 */
export function isQueryMoreExplicitThanQuery(
  target: Query,
  source: Query,
): Query | undefined {
  if (!target || !source) {
    return;
  }

  const targetKeys = Object.keys(target);
  const sourceKeys = Object.keys(source);

  // sourceQuery: {}; targetQuery: { something }
  if (!sourceKeys.length) {
    return target;
  }

  // sourceQuery: { something }; targetQuery: {}
  if (!targetKeys.length) {
    return source;
  }

  if (targetKeys.every((key) => _isEqual(target[key], source[key]))) {
    // every property of target is exactly in source
    return source;
  }

  if (sourceKeys.every((key) => _isEqual(target[key], source[key]))) {
    // every property of source is exactly in target
    return target;
  }

  return;
}

export function areQueriesOverlapping(target: Query, source: Query): boolean {
  if (!target || !source) {
    return false;
  }

  const targetKeys = Object.keys(target);
  const sourceKeys = Object.keys(source);

  if (!sourceKeys.length || !targetKeys.length) {
    return false;
  }

  if (
    targetKeys.some((x) => sourceKeys.includes(x)) ||
    sourceKeys.some((x) => targetKeys.includes(x))
  ) {
    return true;
  }

  return false;
}

if (import.meta.vitest) {
  const { describe, it, expect } = import.meta.vitest;

  describe("areQueriesOverlapping", function () {
    it("empty", function () {
      const query = areQueriesOverlapping({}, {});

      expect(query).toBe(false);
    });

    it("share same properties", function () {
      const query = areQueriesOverlapping({ id: 1 }, { id: 1 });

      expect(query).toBe(true);
    });

    it("share same properties with different values", function () {
      const query = areQueriesOverlapping({ id: 1 }, { id: 2 });

      expect(query).toBe(true);
    });

    it("share some properties", function () {
      const query = areQueriesOverlapping(
        { id: 1, test1: true, test2: true },
        { id: 2, test3: true, test4: true },
      );

      expect(query).toBe(true);
    });

    it("do not share properties", function () {
      const query = areQueriesOverlapping({ id: 1 }, { test: true });

      expect(query).toBe(false);
    });
  });

  describe("isQueryMoreExplicitThanQuery", function () {
    it("empty", function () {
      const query = isQueryMoreExplicitThanQuery({}, {});

      expect(query).toStrictEqual({});
    });

    it("query1 is empty", function () {
      const query = isQueryMoreExplicitThanQuery({}, { id: 1 });

      expect(query).toStrictEqual({ id: 1 });
    });

    it("query2 is empty", function () {
      const query = isQueryMoreExplicitThanQuery({ id: 1 }, {});

      expect(query).toStrictEqual({ id: 1 });
    });

    it("query1 is superset of query2", function () {
      const query = isQueryMoreExplicitThanQuery(
        { id: 1, test: true },
        { id: 1 },
      );

      expect(query).toStrictEqual({ id: 1, test: true });
    });

    it("query2 is superset of query1", function () {
      const query = isQueryMoreExplicitThanQuery(
        { id: 1 },
        { id: 1, test: true },
      );

      expect(query).toStrictEqual({ id: 1, test: true });
    });

    it("queries do not overlap", function () {
      const query = isQueryMoreExplicitThanQuery({ id: 1 }, { test: true });

      expect(query).toBeUndefined();
    });

    it("queries overlap but differ", function () {
      const query = isQueryMoreExplicitThanQuery({ id: 1 }, { id: 2 });

      expect(query).toBeUndefined();
    });
  });
}