UnlyEd/ra-data-graphql-prisma

View on GitHub
src/buildVariables.ts

Summary

Maintainability
F
4 days
Test Coverage
/* eslint-disable @typescript-eslint/camelcase */
import { IntrospectionField, IntrospectionInputObjectType, IntrospectionNamedTypeRef, IntrospectionObjectType } from 'graphql';
import get from 'lodash/get';
import isDate from 'lodash/isDate';
import isObject from 'lodash/isObject';
import { CREATE, DELETE, GET_LIST, GET_MANY, GET_MANY_REFERENCE, GET_ONE, UPDATE } from 'ra-core';
import { IntrospectionResult, Resource } from './constants/interfaces';

import { PRISMA_CONNECT, PRISMA_DISCONNECT, PRISMA_SET, PRISMA_UPDATE } from './constants/mutations';
import { CreateParams, DeleteParams, GetListParams, GetManyParams, GetManyReferenceParams, GetOneParams, Params, UpdateParams } from './types/Params';
import { RAGqlPrismaFieldAliasResolver } from './types/RAGqlPrismaFieldAliasResolver';
import { computeFieldsToAddRemoveUpdate } from './utils/computeAddRemoveUpdate';

import getFinalType from './utils/getFinalType';

//TODO: Object filter weren't tested yet
const buildGetListVariables = (introspectionResults: IntrospectionResult) => (
  resource: Resource,
  aorFetchType: string,
  params: GetListParams,
) => {
  const filter = Object.keys(params.filter).reduce((acc, key) => {
    if (key === 'ids') {
      return { ...acc, id_in: params.filter[key] };
    }

    if (Array.isArray(params.filter[key])) {

      const type = introspectionResults.types.find(
        (t) => t.name === `${resource.type.name}WhereInput`,
      ) as IntrospectionInputObjectType;
      const inputField = type.inputFields.find((t) => t.name === key);

      if (!!inputField) {
        return {
          ...acc,
          [key]: { id_in: params.filter[key] },
        };
      }
    }

    if (isObject(params.filter[key])) {

      const type = introspectionResults.types.find(
        (t) => t.name === `${resource.type.name}WhereInput`,
      ) as IntrospectionInputObjectType;
      const filterSome = type.inputFields.find((t) => t.name === `${key}_some`);

      if (filterSome) {
        const filter = Object.keys(params.filter[key]).reduce(
          (acc, k: string) => ({
            ...acc,
            [`${k}_in`]: params.filter[key][k] as string[],
          }),
          {} as { [key: string]: string[] },
        );
        return { ...acc, [`${key}_some`]: filter };
      }
    }

    const parts = key.split('.');

    if (parts.length > 1) {
      if (parts[1] == 'id') {
        const type = introspectionResults.types.find(
          (t) => t.name === `${resource.type.name}WhereInput`,
        ) as IntrospectionInputObjectType;
        const filterSome = type.inputFields.find(
          (t) => t.name === `${parts[0]}_some`,
        );

        if (filterSome) {
          return {
            ...acc,
            [`${parts[0]}_some`]: { id: params.filter[key] },
          };
        }

        return { ...acc, [parts[0]]: { id: params.filter[key] } };
      }

      const resourceField = (resource.type as IntrospectionObjectType).fields.find(
        (f) => f.name === parts[0],
      )!;
      if ((resourceField.type as IntrospectionNamedTypeRef).name === 'Int') {
        return { ...acc, [key]: parseInt(params.filter[key]) };
      }
      if ((resourceField.type as IntrospectionNamedTypeRef).name === 'Float') {
        return { ...acc, [key]: parseFloat(params.filter[key]) };
      }
    }

    return { ...acc, [key]: params.filter[key] };
  }, {});

  return {
    skip: (params.pagination.page - 1) * params.pagination.perPage,
    first: params.pagination.perPage,
    orderBy: `${params.sort.field}_${params.sort.order}`,
    where: filter,
  };
};

const findInputFieldForType = (
  introspectionResults: IntrospectionResult,
  typeName: string,
  field: string,
): IntrospectionNamedTypeRef | null => {
  const type = introspectionResults.types.find(
    (t) => t.name === typeName,
  ) as IntrospectionInputObjectType;

  if (!type) {
    return null;
  }
  // if field fish with Ids, its an array of relation

  const inputFieldType = type.inputFields.find((t) => t.name === field);

  return !!inputFieldType ? getFinalType(inputFieldType.type) : null;
};

const inputFieldExistsForType = (
  introspectionResults: IntrospectionResult,
  typeName: string,
  field: string,
): boolean => {
  return !!findInputFieldForType(introspectionResults, typeName, field);
};

const buildReferenceField = (
  {
    inputArg,
    introspectionResults,
    typeName,
    field,
    mutationType,
  }: {
    inputArg: { [key: string]: any };
    introspectionResults: IntrospectionResult;
    typeName: string;
    field: string;
    mutationType: string;
  }) => {
  const inputType: IntrospectionNamedTypeRef | null = findInputFieldForType(
    introspectionResults,
    typeName,
    field,
  );
  const mutationInputType: IntrospectionNamedTypeRef | null = findInputFieldForType(
    introspectionResults,
    inputType!.name,
    mutationType,
  );

  return Object.keys(inputArg).reduce((acc, key) => {
    if (!mutationInputType) {
      console.error(`Couldn't find a GraphQL mutation type of type "${mutationType}" for "${inputType?.name}" input type through introspection. Field "${field}" has been ignored.`);
      return acc;
    }
    return ((Object.keys(acc).length === 0) && inputFieldExistsForType(
      introspectionResults,
      mutationInputType!.name,
      key,
    ))
      ? { ...acc, [key]: inputArg[key] }
      : acc;
  }, {});
};

const buildUpdateVariables = (introspectionResults: IntrospectionResult) => (
  resource: Resource,
  aorFetchType: string,
  params: UpdateParams,
  fieldAliasResolver?: RAGqlPrismaFieldAliasResolver,
) => {
  return Object.keys(params.data).reduce(
    (acc, fieldName: string) => {
      let value: any | any[] = params.data[fieldName];
      let previousValue: any | any[] = params.previousData[fieldName];

      if (Array.isArray(value)) {

        // if key finish with Ids, its an array of relation
        if (/Ids$/.test(fieldName)) {
          previousValue = params.previousData[fieldName].map((id: string) => ({ id }));
          //we remove Ids form field
          fieldName = fieldName.replace(/Ids$/, '');
          //and put id in the array
          value = value.map((id: string) => ({ id }));
        }

        const inputType = findInputFieldForType(
          introspectionResults,
          `${resource.type.name}UpdateInput`,
          fieldName,
        );

        if (!inputType) {
          return acc;
        }

        // if its an array, it can be an array of relation or an array of Scalar
        // we check the corresponding input in introspectionresult to know if it use "set" or something else

        const hasConnectMethod = findInputFieldForType(
          introspectionResults,
          inputType.name,
          'connect',
        );
        if (!hasConnectMethod) {
          return {
            ...acc,
            data: {
              ...acc.data,
              [fieldName]: {
                [PRISMA_SET]: value,
              },
            },
          };
        }

        //if key connect already exist we dont do anything
        const {
          fieldsToAdd,
          fieldsToRemove,
        } = computeFieldsToAddRemoveUpdate(
          previousValue,
          value,
        );
        return {
          ...acc,
          data: {
            ...acc.data,
            [fieldName]: {
              [PRISMA_CONNECT]: fieldsToAdd,
              [PRISMA_DISCONNECT]: fieldsToRemove,
            },
          },
        };
      }

      if (isObject(value) && !isDate(value)) {

        const fieldsToConnect: { [key: string]: any } = buildReferenceField({
          inputArg: value,
          introspectionResults,
          typeName: `${resource.type.name}UpdateInput`,
          field: fieldName,
          mutationType: PRISMA_CONNECT,
        });
        let fieldsToUpdate: { [key: string]: any } = buildReferenceField({
          inputArg: value,
          introspectionResults,
          typeName: `${resource.type.name}UpdateInput`,
          field: fieldName,
          mutationType: PRISMA_UPDATE,
        });

        // XXX We allow either a connect or an update, if there is a node connection, then it gets the priority over node update
        //  I don't see how we could have both a connect and an update at the same time (for the same field), it wouldn't make any sense
        if (Object.keys(fieldsToConnect).length === 0) {
          if (Object.keys(fieldsToUpdate).length === 0) {
            // If no fields in the object are valid, continue
            return acc;
          } else {
            // Else, update the nodes

            // XXX Handle nested relationship updates
            //  i.e: {"theme":{"logo":{"id":"ck809zcgm1jn10b20cci7c61i"}}} (where fieldName = 'theme')
            //  returns: {"theme":{"update":{"logo":"connect":{"id":"ck7xehejau7pq0b20cp7x0trw"}}}}

            // TODO Only handles one level of nested updates, could use recursivity to handle deep nested relationship updates
            //  but I don't have any test case to implement it so I didn't
            fieldsToUpdate = Object.keys(fieldsToUpdate).reduce(
              (subAcc, subFieldName: string) => {
                const subValue: any = fieldsToUpdate[subFieldName];

                if (isObject(subValue) && !isDate(subValue)) {
                  // We only consider the "id" field for deep-nested relationship updates, because that's our use-case
                  const nestedId: string | undefined = get(subValue, 'id');

                  if (typeof nestedId === 'string') {
                    // XXX Handling a nested relationship field update. Must use connect on the updated id
                    return {
                      ...subAcc,
                      [subFieldName]: { [PRISMA_CONNECT]: { id: nestedId } },
                    };
                  }
                }

                // Not a nested relationship, don't transform
                return {
                  ...subAcc,
                  [subFieldName]: subValue,
                };
              },
              {} as { [key: string]: any },
            );

            return {
              ...acc,
              data: {
                ...acc.data,
                [fieldName]: { [PRISMA_UPDATE]: { ...fieldsToUpdate } },
              },
            };
          }
        } else {
          // Else, connect the nodes
          return {
            ...acc,
            data: {
              ...acc.data,
              [fieldName]: { [PRISMA_CONNECT]: { ...fieldsToConnect } },
            },
          };
        }
      }

      // Put id field in a where object
      if (fieldName === 'id' && value) {
        return {
          ...acc,
          where: {
            id: value,
          },
        };
      }

      const type = introspectionResults.types.find(
        (t) => t.name === resource.type.name,
      ) as IntrospectionObjectType;

      // XXX The original author checked if the field is defined in the schema (through introspection),
      //  but with GraphCMS it's more complicated because localised fields don't appear in the schema and cannot be resolved this way
      //  We use the same fieldAliasResolver to resolve whether the field is an alias, and if it is then we include it in the mutation
      let field: IntrospectionField | undefined = type.fields.find((t: IntrospectionField) => t.name === fieldName);

      if (!field) {
        if (typeof field === 'undefined' && fieldAliasResolver) {
          // The field wasn't resolved, it's likely an alias, try to resolve alias
          field = (resource.type as IntrospectionObjectType).fields.find(
            (field: IntrospectionField) => {
              return field.name === fieldAliasResolver(field, fieldName, acc, introspectionResults);
            },
          );
        }
      }

      if (field) {
        // Rest should be put in data object

        return {
          ...acc,
          data: {
            ...acc.data,
            [fieldName]: value,
          },
        };
      }

      return acc;
    },
    {} as { [key: string]: any },
  );
};

const buildCreateVariables = (introspectionResults: IntrospectionResult) => (
  resource: Resource,
  aorFetchType: string,
  params: CreateParams,
  fieldAliasResolver?: RAGqlPrismaFieldAliasResolver,
) => {
  return Object.keys(params.data).reduce(
    (acc, fieldName: string) => {
      let data = params.data[fieldName];
      if (Array.isArray(data)) {

        // if key finish with Ids, its an array of relation
        if (/Ids$/.test(fieldName)) {
          //we remove Ids form field
          fieldName = fieldName.replace(/Ids$/, '');
          //and put id in the array
          data = data.map((id: string) => ({ id }));
        }

        const entryIsObject = data.some((entry: any) => isObject(entry) && !isDate(entry));

        if (entryIsObject) {
          data = data.map((entry: any) => Object.keys(entry)
            .reduce((obj: any, key: any) => {
              if (key === 'id') {
                obj[key] = entry[key];
              }
              return obj;
            }, {}));
        }

        const inputType = findInputFieldForType(
          introspectionResults,
          `${resource.type.name}CreateInput`,
          fieldName,
        );
        if (!inputType) {
          return acc;
        }

        // if its an array, it can be an array of relation or an array of Scalar
        // we check the corresponding input in introspectionresult to know if it use "set" or something else

        const hasSetMethod = findInputFieldForType(
          introspectionResults,
          inputType.name,
          'set',
        );

        if (hasSetMethod) {
          return {
            ...acc,
            data: {
              ...acc.data,
              [fieldName]: {
                [PRISMA_SET]: data,
              },
            },
          };
        }

        return {
          ...acc,
          data: {
            ...acc.data,
            [fieldName]: {
              [PRISMA_CONNECT]: data,
            },
          },
        };
      }

      if (isObject(data) && !isDate(data)) {
        const fieldsToConnect = buildReferenceField({
          inputArg: data,
          introspectionResults,
          typeName: `${resource.type.name}CreateInput`,
          field: fieldName,
          mutationType: PRISMA_CONNECT,
        });
        // If no fields in the object are valid, continue
        if (Object.keys(fieldsToConnect).length === 0) {
          return acc;
        }

        // Else, connect the nodes
        return {
          ...acc,
          data: {
            ...acc.data,
            [fieldName]: { [PRISMA_CONNECT]: { ...fieldsToConnect } },
          },
        };
      }

      // Put id field in a where object
      if (fieldName === 'id' && params.data[fieldName]) {
        return {
          ...acc,
          where: {
            id: params.data[fieldName],
          },
        };
      }

      const type = introspectionResults.types.find(
        (t) => t.name === resource.type.name,
      ) as IntrospectionObjectType;

      // XXX The original author checked if the field is defined in the schema (through introspection),
      //  but with GraphCMS it's more complicated because localised fields don't appear in the schema and cannot be resolved this way
      //  We use the same fieldAliasResolver to resolve whether the field is an alias, and if it is then we include it in the mutation
      let field: IntrospectionField | undefined = type.fields.find((t: IntrospectionField) => t.name === fieldName);

      if (!field) {
        if (typeof field === 'undefined' && fieldAliasResolver) {
          // The field wasn't resolved, it's likely an alias, try to resolve alias
          field = (resource.type as IntrospectionObjectType).fields.find(
            (field: IntrospectionField) => {
              return field.name === fieldAliasResolver(field, fieldName, acc, introspectionResults);
            },
          );
        }
      }

      if (field) {
        // Rest should be put in data object
        return {
          ...acc,
          data: {
            ...acc.data,
            [fieldName]: data,
          },
        };
      }

      return acc;
    },
    {} as { [key: string]: any },
  );
};

export default (introspectionResults: IntrospectionResult) => (
  resource: Resource,
  aorFetchType: string,
  params: Params,
  fieldAliasResolver?: RAGqlPrismaFieldAliasResolver,
) => {
  switch (aorFetchType) {
    case GET_LIST: {
      return buildGetListVariables(introspectionResults)(
        resource,
        aorFetchType,
        params as GetListParams,
      );
    }
    case GET_MANY:
      return {
        where: { id_in: (params as GetManyParams).ids },
      };
    case GET_MANY_REFERENCE: {
      const parts = (params as GetManyReferenceParams).target.split('.');

      return {
        where: { [parts[0]]: { id: (params as GetOneParams).id } },
      };
    }
    case GET_ONE:
      return {
        where: { id: (params as GetOneParams).id },
      };
    case UPDATE: {
      return buildUpdateVariables(introspectionResults)(
        resource,
        aorFetchType,
        params as UpdateParams,
        fieldAliasResolver,
      );
    }

    case CREATE: {
      return buildCreateVariables(introspectionResults)(
        resource,
        aorFetchType,
        params as CreateParams,
        fieldAliasResolver,
      );
    }

    case DELETE:
      return {
        where: { id: (params as DeleteParams).id },
      };
  }
};