graphql-nexus / nexus

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

Backing types API #277

Open Weakky opened 5 years ago

Weakky commented 5 years ago

What are backing types?

Backing types represent the typescript types used to define the root of a resolver.

Motivation

TBD

Proposal

TBD

Weakky commented 5 years ago

Updated (& incomplete) version

What are backing types?

Backing types represent the typescript types used to define the root of a resolver.

So far, they have been either inferred by nexus or defined using the typegenAutoConfig nexus global configuration.

Motivation

The current backing type API allows two ways to interact with it:

It's either one or the other. The only way to change a backing type if it's based on a source, is by writing a whole new definition.

While it's fine if your backing types were hand-written, it causes problem with generated backing types.

Explanation

Let's consider the following source:

// source1.ts

type User = {
  id:   string
  name: string
}

Defined in makeSchema:

// schema.ts
import { makeSchema } from 'nexus'

makeSchema({
  typegenAutoConfig: {
    sources: [{
      path: require.resolve('./source1.ts'),
      alias: "source1"
    }]
  }
})

Resulting in the following changes in the nexus typegen:

// nexus-typegen.ts
+ import * as source1 from './source1.ts'

export interface NexusGenRootTypes {
-  User: { ... }
+  User: source1.User;
  String: string;
  Int: number;
  Float: number;
  Boolean: boolean;
  ID: string;
  DateTime: any;
}

Now, the only way to alter source1.User is to either manually modify source1.User (if it was hand-written), or to handwrite a whole new User type and override source1.User, like so:

// custom-source.ts
import { User as UserBase } from './source1.ts'

type User = UserBase & {
  newField: string
}
// schema.ts
import { makeSchema } from 'nexus'

makeSchema({
  typegenAutoConfig: {
    sources: [
      {
        path: require.resolve('./source1.ts'),
        alias: "source1"
        },
      {
        path: require.resolve('./custom-source.ts'),
        alias: "custom_source"
      } // Order matters, `custom_source` will override `source1` if two types have the same name
    ]
  }
})

Resulting in the following changes in the nexus typegen:

// nexus-typegen.ts
  import * as source1 from './source1.ts'
+ import * as custom_source from './custom-source'

export interface NexusGenRootTypes {
-  User: source1.User;
+  User: custom_source.User;
  String: string;
  Int: number;
  Float: number;
  Boolean: boolean;
  ID: string;
  DateTime: any;
}

Proposal: Combine nexus inferring system & sources

In order for this to work, we need a couple of rules:

Example 1: No resolver on relations means "eager"

Let's consider the following source:

// source1.ts

type User = {
  id:   string
  name: string
}

Defined in makeSchema:

import { makeSchema } from 'nexus'

makeSchema({
  typegenAutoConfig: {
    sources: [{
      path: require.resolve('./source1.ts'),
      alias: "source1"
    }]
  }
})

Resulting, in the following changes in the nexus typegen:

+ import * as source1 from './source1.ts'

export interface NexusGenRootTypes {
-  User: { ... }
+  User: source1.User;
  String: string;
  Int: number;
  Float: number;
  Boolean: boolean;
  ID: string;
  DateTime: any;
}

Now let's define an User Object Type:

import { objectType } from 'nexus'

const User = objectType({
  name: 'User',
  definition(t) {
    t.id('id')
    t.string('name')
    t.field('friends', { type: User, list: [true] }) // we make a relation eager by not providing a custom resolver
  }
})

Then Nexus would update nexus typegen as so:

import * as source1 from './source1.ts'

export interface NexusGenRootTypes {
-  User: source1.User;
+  User: source1.User & { friends: User[] };
  String: string;
  Int: number;
  Float: number;
  Boolean: boolean;
  ID: string;
  DateTime: any;
}

👆Because friends is a relation and has no resolver, we can deduce that the relation is eager, and that the parent thus needs to pass the value down to the children.

Weakky commented 5 years ago

Closing now as it turned out not to serve any use-cases

homoky commented 5 years ago

I don't agree it has no use-cases. For caching purpose for example is better option to fetch all data with all relations (at least ids of relation fields). Now the only possible way is to use public field that we don`t want to share via API:

import { idArg, queryField } from 'nexus';

const store = queryField('store', {
  type: 'Store',
  args: {
    id: idArg(),
  },
  resolve: async (_parent, args, { prisma }) => {
    return {
      ...(await prisma.store({ id: args.id })),
      cityId: await prisma
        .store({ id: args.id })
        .city()
        .id(),
    };
  },
});

export default store;
import { objectType } from 'yoga';

const Store = objectType({
  name: 'Store',
  definition(t) {
    t.id('id');
    t.string('name');
    // This field should not be visible via schema to users
    t.string('cityId');
    t.field('city', {
      type: 'City',
      resolve: (parent, _args, { prisma }) => {
        // Now we can grab cache from Redis for example
        return prisma.city({ id: parent.cityId });
      },
    });
  },
});

export default Store;

Or is there any other solution for this (to get it properly typed)?

Weakky commented 5 years ago

Hey @homoky, we've discussed this further and came to the same conclusion: that there are use-cases. Yours being one indeed. The original proposal tried to solve this using the hide property, but we think this is not an elegant API. One way to solve your issue currently is to use typegenAutoConfig.sources as shown on the issue above. We'll re-open the issue once we have a better-refined proposal. In the meantime, feel free to open a new one or comment here if you have ideas to solve your issue better than the current solution 🙏

homoky commented 5 years ago

Thank you for your quick reply. Personaly I like your proposal. The fifth example is what I am looking for exactly. We talked about it on the Slack few months ago and this is far more I`ve expected.

By far I think there are many people who want to use this, even if you don't know about them. The simplest way around is to pass those values from queries and ask the parent for the value but with // @ts-ignore, otherwise it will yell that the property does not exists on the parent object. Like this:

import { objectType } from 'yoga';

const User = objectType({
  name: 'User',
  definition(t) {
    t.id('id');
    t.field('ratings', {
      type: 'Rating',
      list: true,
      resolve: (parent, _args, { redis }) => {
        // @ts-ignore
        const ratingIds = parent.ratingIds;

        if (ratingIds) {
          return redis.getKeyValues(ratingIds)
        }

        return null
      }
    });
  },
});

export default User;

So this is great step in the right direction.