Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Idea: input types with built-in transform #1290

Open
macrozone opened this issue Sep 5, 2024 · 9 comments
Open

Idea: input types with built-in transform #1290

macrozone opened this issue Sep 5, 2024 · 9 comments

Comments

@macrozone
Copy link

macrozone commented Sep 5, 2024

Sometimes you want to create multiple queries and mutations that all use the same input object and want to pass that to your orm, like prisma:

builder.queryField("project", (t) =>
  t.prismaField({
    type: "Project",
    args: {
      where: t.arg({
        required: true,
        type: ProjectWhereUniqeInputRef,
      }),
    },
    nullable: true,
    resolve: async (query, parent, { where }) => {
      return prisma.organisation.findUnique({
        ...query,
        where
      });
    },
  }),
);

builder.mutationField("updateProject", (t) =>
  t.prismaField({
    type: "Project",
    args: {
      data: ....
      where: t.arg({
        type: ProjectWhereUniqeInputRef,
        required: true,
      }),
    },
    resolve: async (query, parent, { data, where }) => {
      return prisma.project.update({
        data,
        where,
        ...query,
      });
    },
  }),
);

however, this requires that the backing type of ProjectWhereUniqeInputRef has the same shape as prisma requires as were.

If its not the same, you have to transform it, but you have to do that on each query:

builder.queryField("project", (t) =>
  t.prismaField({
    type: "Project",
    args: {
      where: t.arg({
        required: true,
        type: ProjectWhereUniqeInputRef,
      }),
    },
    nullable: true,
    resolve: async (query, parent, { where }) => {
      return prisma.organisation.findUnique({
        ...query,
        where: await transformProjectWhere(where) // transform
      });
    },
  }),
);

builder.mutationField("updateProject", (t) =>
  t.prismaField({
    type: "Project",
    args: {
      data: ....
      where: t.arg({
        type: ProjectWhereUniqeInputRef,
        required: true,
      }),
    },
    resolve: async (query, parent, { data, where }) => {
      return prisma.project.update({
        data,
        where: await transformProjectWhere(where),
        ...query,
      });
    },
  }),
);

this is of course doable, but can be a bit awkward.

An elegant solution would be that ProjectWhereUniqeInputRef would already do this transformation:

type InputShape = {
  tenantId: string,
  slug: string
}

type TransformedInputShape = {
   tenantId_slug: { // bit a trivial example, but e.g. to "hide" those combined id keys
     tenantId: string,
     slug: string
   }
}
export const ProjectWhereUniqueInputRef = builder
  .inputRef<InputShape, TransformedInputShape>("ProjectWhereUniqueInput")
  .implement({
    fields: (t) => ({
      tenantId: t.string({
        required: true,
      }),
      slug: t.string({
        required: true,

      }),
    }),
transform: (input /* has InputShape */) => { // can be async function as well
     return { // has to return TransformedInputShape
         tenantId_slug: {slug: input.slug, tenantId: input.tenantId}
      }
   }
  });

so wherever you use ProjectWhereUniqueInputRef in an argument, the object has the TransformedInputShape in the resolver instead of InputShape

@hayes
Copy link
Owner

hayes commented Sep 6, 2024

We probably won't be able to add this to pothos/core, but something similar should be achievable through a plugin.

There are existing mechanism in pothos that allow plugins to transform inputs. The API would likely be something like const InputRef = builder.inputRef(...).transform((data) => transformData(data).

I have been intending to implement a new validation plugin that works this way (the biggest drawback of the zod plugin was the lack of transforms). The goal would be to support arbitrary validations + transforms without being tied to a specific validation library.

Implementing a plugin that just handles transformations would be relatively easy. I can try to find some time this weekend to put together a quick prototype that you can use to add that as a custom plugin

@macrozone
Copy link
Author

that sounds good, a plugin would be good enough

@hayes
Copy link
Owner

hayes commented Sep 8, 2024

Here's a basic transform plugin you can use/modify to fit your use-case

import { createServer } from 'node:http';
import type { PothosOutputFieldConfig, SchemaTypes } from '@pothos/core';
import SchemaBuilder, {
  BasePlugin,
  createInputValueMapper,
  InputObjectRef,
  mapInputFields,
  resolveInputTypeConfig,
} from '@pothos/core';
import { createYoga } from 'graphql-yoga';

declare global {
  namespace PothosSchemaTypes {
    export interface InputObjectRef<Types extends SchemaTypes, T> {
      transform: <U>(fn: (value: T) => U) => InputObjectRef<Types, U>;
    }

    export interface Plugins<Types extends SchemaTypes> {
      transformInputs: TransformInputsPlugin<Types>;
    }
  }
}

InputObjectRef.prototype.transform = function (
  this: InputObjectRef<SchemaTypes, unknown>,
  fn: (value: unknown) => unknown,
) {
  this.updateConfig((config) => {
    return {
      ...config,
      extensions: {
        ...config.extensions,
        inputTransformFunctions: [
          ...((config.extensions?.inputTransformFunctions as []) ?? []),
          fn,
        ],
      },
    };
  });

  return this;
};

export class TransformInputsPlugin<Types extends SchemaTypes> extends BasePlugin<Types> {
  override onOutputFieldConfig(
    fieldConfig: PothosOutputFieldConfig<Types>,
  ): PothosOutputFieldConfig<Types> | null {
    const argMappings = mapInputFields(fieldConfig.args, this.buildCache, (inputField) => {
      let inputTypeConfig: ReturnType<typeof resolveInputTypeConfig> | null;
      try {
        inputTypeConfig = resolveInputTypeConfig(inputField.type, this.buildCache);
      } catch (_) {
        inputTypeConfig = null;
      }

      if (inputTypeConfig?.extensions?.inputTransformFunctions) {
        return (input: unknown) =>
          input == null
            ? input
            : (
                inputTypeConfig.extensions?.inputTransformFunctions as ((
                  value: unknown,
                ) => unknown)[]
              ).reduce<unknown>((acc, fn) => fn(acc), input);
      }

      return null;
    });

    if (!argMappings) {
      return fieldConfig;
    }

    const argMapper = createInputValueMapper(argMappings, (value, mapping) =>
      mapping.value ? mapping.value(value) : value,
    );

    return {
      ...fieldConfig,
      argMappers: [...(fieldConfig.argMappers ?? []), (args) => argMapper(args)],
    };
  }
}

SchemaBuilder.registerPlugin('transformInputs', TransformInputsPlugin);

const builder = new SchemaBuilder<{}>({
  plugins: ['transformInputs'],
});

const TransformedInput = builder
  .inputType('ToTransform', {
    fields: (t) => ({
      value: t.string({ required: true }),
    }),
  })
  .transform(({ value }) => ({
    number: Number.parseInt(value, 10),
  }));

builder.queryType({
  fields: (t) => ({
    test: t.int({
      args: {
        input: t.arg({
          required: true,
          type: TransformedInput,
        }),
        inputList: t.arg({
          required: true,
          type: [TransformedInput],
        }),
      },
      resolve: (_, args) => {
        console.log(args);
        return args.inputList.reduce((a, b) => a + b.number, args.input.number);
      },
    }),
  }),
});

const yoga = createYoga({
  schema: builder.toSchema(),
  maskedErrors: false,
});

const server = createServer(yoga);

const port = 3000;
server.listen(port);

@macrozone
Copy link
Author

macrozone commented Sep 9, 2024

Here's a basic transform plugin you can use/modify to fit your use-case

(...)

thank you very much, i'll try that out

@macrozone
Copy link
Author

macrozone commented Sep 9, 2024

@hayes i tried it, it works for sync transforms.

unfortunatly, it does not work with async function, or at least, not directly.
you have to await the argument in your resolver.

i tried to see whether I can fix this in the plugin, but it looks like this is deep down in pothos core where the mapper is called and i did not see whether you can resolve the promise somewhere there

@hayes
Copy link
Owner

hayes commented Sep 9, 2024

I would be very hesitant to add a sync input transforms into Pothos. What kinds of transforms are you doing that require async calls?

The argument mappers are used in paces that can't easily be made async. You can definitely handle async mapping in a custom plugin, but you would need to use the wrapResolver hook, and not use the built-in arg mapper.

@macrozone
Copy link
Author

I would be very hesitant to add a sync input transforms into Pothos. What kinds of transforms are you doing that require async calls?

The argument mappers are used in paces that can't easily be made async. You can definitely handle async mapping in a custom plugin, but you would need to use the wrapResolver hook, and not use the built-in arg mapper.

the transform require and additional db request in my case.

Since you mentioned validation as use case, I am pretty sure a common request will be for async validation 😉

isn't the mapper also using the wrapResolver?

@hayes
Copy link
Owner

hayes commented Sep 10, 2024

isn't the mapper also using the wrapResolver?

It does internally, but the built in mapping won't handle anything async, so you would need to use wrapResolver directly

I think if you are making db calls, input transforms are probably the wrong place to be doing that. I would recommend just abstracting into into a re-usable helper.

Async validation is also a contentious topic. It hasn't been supported in pothos, and I don't think I've seen any requests for it before now. This would be another place where I'd probably leave it up to being handled inside the resolver if you need to do async work

@macrozone
Copy link
Author

isn't the mapper also using the wrapResolver?

It does internally, but the built in mapping won't handle anything async, so you would need to use wrapResolver directly

I think if you are making db calls, input transforms are probably the wrong place to be doing that. I would recommend just abstracting into into a re-usable helper.

Async validation is also a contentious topic. It hasn't been supported in pothos, and I don't think I've seen any requests for it before now. This would be another place where I'd probably leave it up to being handled inside the resolver if you need to do async work

i wouldn't necessary think its wrong to do input transforms with the help of some async function and technically there is nothing that I see that should prevent it (outside from the refactoring effort to put async/await in the right spot). I understand that you can't make everything async. But in this case, I think it should be completly doable.

i understand input transform as some kind of "middleware", same as any wrapResolver. Its maybe a bit funky though, if the input type has code that does db or any ai call though, but It would really be extremly helpful.

What alternatives do i have otherwise? (apart from just not abstracting it at all)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants