feathersjs/feathers-hooks-common

View on GitHub
src/hooks/populate.ts

Summary

Maintainability
C
1 day
Test Coverage
import _get from 'lodash/get.js';
import _set from 'lodash/set.js';
import { BadRequest } from '@feathersjs/errors';

import { getItems } from '../utils/get-items';
import { replaceItems } from '../utils/replace-items';
import type { HookContext } from '@feathersjs/feathers';

export interface PopulateOptions<H extends HookContext = HookContext> {
  schema:
    | Partial<PopulateSchema>
    | ((context: H, options: PopulateOptions<H>) => Partial<PopulateSchema>);
  checkPermissions?: (context: H, path: string, permissions: any, depth: number) => boolean;
  profile?: boolean;
}

export interface PopulateSchema {
  /**
   * The name of the service providing the items, actually its path.
   */
  service: string;
  /**
   * Where to place the items from the join
   * dot notation
   */
  nameAs: string;
  /**
   * The name of the field in the parent item for the relation.
   * dot notation
   */
  parentField: string;
  /**
   * The name of the field in the child item for the relation.
   * Dot notation is allowed and will result in a query like { 'name.first': 'John' } which is not suitable for all DBs.
   * You may use query or select to create a query suitable for your DB.
   */
  childField: string;

  /**
   * Who is allowed to perform this join. See checkPermissions above.
   */
  permissions: any;

  /**
   * An object to inject into context.params.query.
   */
  query: any;

  /**
   * A function whose result is injected into the query.
   */
  select: (context: HookContext, parentItem: any, depth: number) => any;

  /**
   * Force a single joined item to be stored as an array.
   */
  asArray: boolean;

  /**
   * Controls pagination for this service.
   */
  paginate: boolean | number;

  /**
   * Perform any populate or fastJoin registered on this service.
   */
  useInnerPopulate: boolean;
  /**
   * Call the service as the server, not with the client’s transport.
   */
  provider: string;
  include: Partial<PopulateSchema> | Partial<PopulateSchema>[];
}

export function populate<H extends HookContext = HookContext>(options: PopulateOptions<H>) {
  // options.schema is like { service: '...', permissions: '...', include: [ ... ] }

  const typeofSchema = typeof options.schema;
  if ((typeofSchema !== 'object' || options.schema === null) && typeofSchema !== 'function') {
    throw new Error('Options.schema is not an object. (populate)');
  }

  return function (context: H) {
    const optionsDefault: PopulateOptions = {
      schema: {},
      checkPermissions: () => true,
      profile: false,
    };

    // @ts-ignore
    if (context.params._populate === 'skip') {
      // this service call made from another populate
      return context;
    }

    return Promise.resolve()
      .then(() => {
        // 'options.schema' resolves to { permissions: '...', include: [ ... ] }

        const items = getItems(context);
        const options1 = Object.assign({}, optionsDefault, options);
        const { schema, checkPermissions } = options1;

        const schema1 = typeof schema === 'function' ? schema(context, options1) : schema;
        const permissions = schema1.permissions || null;
        const baseService = schema1.service;
        const provider = 'provider' in schema1 ? schema1.provider : context.params.provider;

        if (typeof checkPermissions !== 'function') {
          throw new BadRequest('Permissions param is not a function. (populate)');
        }

        if (baseService && context.path && baseService !== context.path) {
          throw new BadRequest(`Schema is for ${baseService} not ${context.path}. (populate)`);
        }

        if (permissions && !checkPermissions(context, context.path, permissions, 0)) {
          throw new BadRequest('Permissions do not allow this populate. (populate)');
        }

        if (typeof schema1 !== 'object') {
          throw new BadRequest('Schema does not resolve to an object. (populate)');
        }

        const include = [].concat((schema1.include || []) as any).map(schema => {
          if ('provider' in schema) {
            return schema;
          } else {
            return Object.assign({}, schema, { provider });
          }
        });

        return !include.length ? items : populateItemArray(options1, context, items, include, 0);
      })
      .then(items => {
        replaceItems(context, items);
        return context;
      });
  };
}

function populateItemArray(
  options: any,
  context: HookContext,
  items: any,
  includeSchema: any,
  depth: number,
): any {
  // 'items' is an item or an array of items
  // 'includeSchema' is like [ { nameAs: 'author', ... }, { nameAs: 'readers', ... } ]

  if (items.toJSON || items.toObject) {
    throw new BadRequest('Populate requires results to be plain JavaScript objects. (populate)');
  }

  if (!Array.isArray(items)) {
    return populateItem(options, context, items, includeSchema, depth + 1);
  }

  return Promise.all(
    items.map(item => populateItem(options, context, item, includeSchema, depth + 1)),
  );
}

function populateItem(
  options: any,
  context: HookContext,
  item: any,
  includeSchema: any,
  depth: number,
): any {
  // 'item' is one item
  // 'includeSchema' is like [ { nameAs: 'author', ... }, { nameAs: 'readers', ... } ]

  const elapsed: any = {};
  const startAtAllIncludes = new Date().getTime();
  const include = [].concat(includeSchema || []) as any;
  if (!Object.prototype.hasOwnProperty.call(item, '_include')) item._include = [];

  return Promise.all(
    include.map((childSchema: any) => {
      const { query, select, parentField } = childSchema;

      // A related column join is required if neither the query nor select options are provided.
      // That requires item[parentField] exist. (The DB handles child[childField] existence.)
      if (!query && !select && (!parentField || _get(item, parentField) === undefined)) {
        return undefined;
      }

      const startAtThisInclude = new Date().getTime();
      return populateAddChild(options, context, item, childSchema, depth).then((result: any) => {
        const nameAs = childSchema.nameAs || childSchema.service;
        elapsed[nameAs] = getElapsed(options, startAtThisInclude, depth);

        return result;
      });
    }),
  ).then(children => {
    // 'children' is like
    //   [{ nameAs: 'authorInfo', items: {...} }, { nameAs: readersInfo, items: [{...}, {...}] }]
    if (options.profile !== false) {
      elapsed.total = getElapsed(options, startAtAllIncludes, depth);
      item._elapsed = elapsed;
    }

    children.forEach(child => {
      if (child) {
        _set(item, child.nameAs, child.items);
      }
    });

    return item;
  });
}

function populateAddChild(
  options: any,
  context: HookContext,
  parentItem: any,
  childSchema: any,
  depth: any,
): any {
  /*
  @params
    'parentItem' is the item we are adding children to
    'childSchema' is like
      { service: 'comments',
        permissions: '...',
        nameAs: 'comments',
        asArray: true,
        parentField: 'id',
        childField: 'postId',
        query: { $limit: 5, $select: ['title', 'content', 'postId'], $sort: { createdAt: -1 } },
        select: (context, parent, depth) => ({ something: { $exists: false }}),
        paginate: false,
        provider: context.provider,
        useInnerPopulate: false,
        include: [ ... ] }
  @returns { nameAs: string, items: array }
  */

  const {
    childField,
    paginate,
    parentField,
    permissions,
    query,
    select,
    service,
    useInnerPopulate,
    provider,
  } = childSchema;

  if (!service) {
    throw new BadRequest('Child schema is missing the service property. (populate)');
  }

  // A related column join is required if neither the query nor select options are provided.
  if (!query && !select && !(parentField && childField)) {
    throw new BadRequest('Child schema is missing parentField or childField property. (populate)');
  }

  if (permissions && !options.checkPermissions(context, service, permissions, depth)) {
    throw new BadRequest(`Permissions for ${service} do not allow include. (populate)`);
  }

  const nameAs = childSchema.nameAs || service;
  if (parentItem._include.indexOf(nameAs) === -1) parentItem._include.push(nameAs);

  return Promise.resolve()
    .then(() => (select ? select(context, parentItem, depth) : {}))
    .then(selectQuery => {
      let sqlQuery = {};

      if (parentField) {
        const parentVal = _get(parentItem, parentField); // will not be undefined
        sqlQuery = { [childField]: Array.isArray(parentVal) ? { $in: parentVal } : parentVal };
      }

      const queryObj = Object.assign(
        {},
        query,
        sqlQuery,
        selectQuery, // dynamic options override static ones
      );

      const serviceHandle = context.app.service(service);

      if (!serviceHandle) {
        throw new BadRequest(`Service ${service} is not configured. (populate)`);
      }

      let paginateObj: any = { paginate: false };
      const paginateOption = paginate;
      if (paginateOption === true) {
        paginateObj = null;
      }
      if (typeof paginateOption === 'number') {
        paginateObj = { paginate: { default: paginateOption } };
      }

      const params = Object.assign(
        {},
        context.params,
        paginateObj,
        { query: queryObj },
        useInnerPopulate ? {} : { _populate: 'skip' },
        'provider' in childSchema ? { provider: childSchema.provider } : {},
      );

      return serviceHandle.find(params);
    })
    .then(result => {
      result = result.data || result;

      if (result.length === 0) {
        return childSchema.asArray ? [] : null;
      }

      if (result.length === 1 && !childSchema.asArray) {
        result = result[0];
      }

      const include = [].concat(childSchema.include || []).map(schema => {
        if ('provider' in schema) {
          return schema;
        } else {
          return Object.assign({}, schema, { provider });
        }
      });

      return childSchema.include && result
        ? populateItemArray(options, context, result, include, depth)
        : result;
    })
    .then(items => ({ nameAs, items }));
}

// Helpers

// used process.hrTime before
function milliToNano(num: number) {
  return num * 1000000;
}

function getElapsed(options: PopulateOptions, startTime: number, depth: number) {
  if (options.profile === true) {
    return milliToNano(new Date().getTime() - startTime + 0.001);
  }

  return depth; // for testing _elapsed
}