graphql-nexus / nexus

Code-First, Type-Safe, GraphQL Schema Construction
https://nexusjs.org
MIT License
3.4k stars 275 forks source link

Feature Request: Input inheritance #907

Open miguelbogota opened 3 years ago

miguelbogota commented 3 years ago

We all know nexus uses a functional programming approach to GraphQL Schemas. I think that's why too many people love it (Me included). But I think having some inheritance added to the inputObjectType can be amazing to have a smaller code.

Take this as an example:

export const AddUserInput = inputObjectType({
  name: "AddUserInput",
  definition(t) {
    t.nonNull.string("username");
    t.nonNull.string("password");
  },
});

export const UpdateUserInput = inputObjectType({
  name: "UpdateUserInput",
  definition(t) {
    t.nonNull.id("id");
    t.string("username");
    t.string("password");
  },
});

To add a new user you need to have the username and password. And to update it you are required to have the id but the username and password are optionals.

This code can be shorten with some sort of partial inheritance.

export const AddUserInput = inputObjectType({
  name: "AddUserInput",
  definition(t) {
    t.nonNull.string("username");
    t.nonNull.string("password");
  },
});

export const UpdateUserInput = partialInputTypeFrom("AddUserInput", {
  name: "UpdateUserInput",
  definition(t) {
    t.nonNull.id("id");
  },
});

This is a simple example but in real apps the list of properties can be longer and repeat the properties twice (1 for adding and 1 for updating) is just a lot of boiler plate code.

tom-sherman commented 3 years ago

You can achieve the same thing through composition, no?

function sharedUserFields(t) {
  t.nonNull.string("username");
  t.nonNull.string("password");
}

export const AddUserInput = inputObjectType({
  name: "AddUserInput",
  definition(t) {
    sharedUserFields(t);
  },
});

export const UpdateUserInput = inputObjectType({
  name: "UpdateUserInput",
  definition(t) {
    t.nonNull.id("id");
    sharedUserFields(t);
  },
});

You could even have a helper that composes multiple of these functions

function sharedUserFields(t) {
  t.nonNull.string("username");
  t.nonNull.string("password");
}

function idField(t) {
  t.nonNull.id("id")
}

export const AddUserInput = inputObjectType({
  name: "AddUserInput",
  definition: composeFields(sharedUserFields),
});

export const UpdateUserInput = inputObjectType({
  name: "UpdateUserInput",
  definition: composeFields(sharedUserFields, idField),
});
miguelbogota commented 3 years ago

Hey @tom-sherman, In your example the fields are nonNull for both AddUser and UpdateUser. Which is not accurate. The idea is to make them nullable in updateUser, since I would like to update only the username or only the password. Not necessarily both.

I came up with the following solution. But it would be a good idea to add some docs regarding this.

interface ValidationArgs<T extends string> {
  t: InputDefinitionBlock<T>;
  name: string;
  required?: boolean;
}

// Have a required/optional field for each type in one file.

const validateString = <T extends string>({ t, name, required = true }: ValidationArgs<T>) =>
  required ? t.nonNull.string(name) : t.string(name);

const validateId = <T extends string>({ t, name, required = true }: ValidationArgs<T>) =>
  required ? t.nonNull.id(name) : t.id(name);

// ------------

// Create a HOF in another file requesting the required property and pass it down.
const sharedInputs = <T extends string>(required: boolean, t: InputDefinitionBlock<T>) => {
  validateString({ t, required, name: "username" });
  validateString({ t, required, name: "password" });
};

// ------------

// Use the HOF and pass the required.

const AddUserInput = inputObjectType({
  name: "AddUserInput",
  definition(t) {
    sharedInputs(true, t);
  },
});

const UpdateUserInput = inputObjectType({
  name: "UpdateUserInput",
  definition(t) {
    validateId({ t, required: true, name: "id" });
    sharedInputs(false, t);
  },
});

Or with a field you can have a single require/optional field like:

export interface FieldArgs<T extends string> extends NexusInputFieldConfig<T, string> {
  nonNull?: boolean;
}

// Have a required/optional field for only the field type.
export const field = <T extends string>(
  t: InputDefinitionBlock<T>,
  name: string,
  { nonNull = true, ...config }: FieldArgs<T>,
) => {
  return nonNull ? t.nonNull.field(name, config) : t.field(name, config);
};

interface SharedInputsArgs<T extends string> {
  nonNull: boolean;
  t: InputDefinitionBlock<T>;
}

// ------------

// Create a HOF in another file requesting the required property and pass it down.
const sharedInputs = <T extends string>({ nonNull, t }: SharedInputsArgs<T>) => {
  field(t, "username", { nonNull, type: "String" });
  field(t, "password", { nonNull, type: "String" });
};

// ------------

// Use the HOF and pass the required.

const AddUserInput = inputObjectType({
  name: "AddUserInput",
  definition(t) {
    sharedInputs({ t, nonNull: true });
  },
});

const UpdateUserInput = inputObjectType({
  name: "UpdateUserInput",
  definition(t) {
    field(t, "id", { nonNull: true, type: "ID" });
    sharedInputs({ t, nonNull: false });
  },
});
Sytten commented 3 years ago

I don't think we will add that in nexus since there is a chance that input inheritance makes its way in the official graphql spec and that would require a breaking change in the library. But I will check with @tgriesser if can add that code in a "recipes" section of the doc.

bentron2000 commented 2 years ago

This is part of the spec isn't it? https://spec.graphql.org/October2021/#sec-Input-Object-Extensions Would really love this :)