Skip to content

Instantly share code, notes, and snippets.

@jasonkuhrt
Last active February 26, 2021 03:15
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jasonkuhrt/fb5dcb58ba9bf68123460138cb17bc40 to your computer and use it in GitHub Desktop.
Save jasonkuhrt/fb5dcb58ba9bf68123460138cb17bc40 to your computer and use it in GitHub Desktop.
Stream of consciousness design of the new Nexus Prisma

This sketch is outdated. Please refer to https://github.com/prisma/nexus-prisma for current spec & status.



Here is a GraphQL object manually projecting fields from the model layer. Assume the parent returned data of a project model.

objectType({
  name: 'Project',
  definition(t) {
    t.id('id')
    t.string('displayName')
    t.list.field('memberships', {
      type: 'ProjectMembership',
      resolve(project, _, ctx) {
        return ctx.prisma.projectMembership.findMany({
          where: {
            projectId: project.id,
          },
        })
      },
    })
  },
})

Field name could be forwarded

objectType({
  name: 'Project',
  definition(t) {
    t.id(models.Project.fields.id.name)
  },
})

Prisma schema descriptions could be projected as Graphql descriptions.

objectType({
  name: 'Project',
  description: models.Project.description,
  definition(t) {
    t.id(models.Project.id.name, {
      description: models.Project.fields.id.description,
    })
  },
})

Field type could be forwarded

Nexus permits string literal references. This means NPP can leverage that. Here TS type of ...id.type would be the string literal ID.

Note: If the TS type of ...id.type were a custom scalar not setup in the graphql api yet, then there will be a static error.

Note: This will require a mapping between Prisma types and TS types... DMMF may already give us this.

objectType({
  name: 'Project',
  definition(t) {
    t.field(models.Project.fields.id.name, {
      type: models.Project.fields.id.type,
      description: models.Project.fields.id.description,
    })
  },
})

Default resolvers could be generated for use

Resolvers are not a Prisma concept, only a GraphQL one, but we can derive resolvers that use Prisma Client at runtime from the Prisma schema information.

objectType({
  name: 'Project',
  definition(t) {
    t.field(models.Project.fields.id.name, {
      type: models.Project.fields.id.type,
      description: models.Project.fields.id.description,
      resolver: models.Project.fields.id.resolver,
    })
  },
})

The resolver implementation would depend upon if the field were a scalar or a type reference. The former would be simpler.

Scalar Resolver

Nexus already has default resolver behaviour for scalars so here the generated resolver could just be undefined and it would work.

Relationship Resolver

For a relationship case like this though...

objectType({
  name: 'Project',
  definition(t) {
    t.field(models.Project.fields.memberships.name, {
      type: models.Project.fields.memberships.type,
      description: models.Project.fields.memberships.description,
      resolver: models.Project.fields.memberships.resolver,
    })
  },
})

The generator would need to provider a generic solution for e.g. (taken from above):

resolve(project, _, ctx) {
  return ctx.prisma.projectMembership.findMany({
    where: {
      projectId: project.id,
    },
  })
},

Thoughts:

  1. ctx The user can make prisma available on the context.

  2. ctx.<prisma> The user can tell NPP where on the context the prisma client is

  3. ctx.<prisma>.<pc_model_prop> NPP will know itself the prisma client property to access for its relationship

  4. ctx.<prisma>.<pc_model_prop>.<pc_op_prop> NPP will know itself the prisma client operation to access for traversing the relationship (always: findMany).

  5. ctx.<prisma>.<pc_model_prop>.<pc_op_prop>({ where: {} }) NPP will know to be looking for things related to the parent, which PC has a standard way of doing via where.

  6. ctx.<prisma>.<pc_model_prop>.<pc_op_prop>({ where: { <model_relation_field_name>: <parent_model>.<related_field_name> } }) NPP will be able to figure out the correct where criteria by asking:

    1. From the DMMF see how the field on this model (a) that relates to the other model (b): some field on B points to some field on A
    2. Thereby: `{ where: { <some_field_on_b>: .<some_field_on_a> } }
    3. To be clear about the above: <A> is source data passed into the resolver by the parent resolver.

No need to generate extensions

The GraphQL.js extensions config exposed in turn by Nexus does not seem to have a use-case here. It can be ignored.

objectType({
  name: 'Project',
  definition(t) {
    t.field(models.Project.fields.memberships.name, {
      extensions: {...}
    })
  },
})

Whole Field Config Forwarding

Instead of field-by-field forwarding like this:

objectType({
  name: 'Project',
  definition(t) {
    t.field(models.Project.fields.memberships.name, {
      type: models.Project.fields.memberships.type,
      description: models.Project.fields.memberships.description,
      resolver: models.Project.fields.memberships.resolver,
    })
  },
})

It is possible to forward the whole config:

objectType({
  name: 'Project',
  definition(t) {
    t.field(
      models.Project.fields.memberships.name,
      models.Project.fields.memberships
    )
  },
})

Nexus currently requires the name to be passed separately from the rest of the config. But it should be possible for Nexus to accept a config in a type safe way too, in which case:

objectType({
  name: 'Project',
  definition(t) {
    t.field(models.Project.fields.memberships)
  },
})

Field Config Spreading

Some config fields only exist at the GraphQL level, and do not make sense to be generated for. NPP should not prevent regular use of those field configurations: extensions, deprecated. Object spreads are a natural solution here:

objectType({
  name: 'Project',
  definition(t) {
    t.field({
      extensions: {...},
      deprecated: true,
      ...models.Project.fields.memberships
    })
  },
})

Resolver Wrapping

This can be naturally expressed:

objectType({
  name: 'Project',
  definition(t) {
    t.field({
      ...models.Project.fields.memberships
      resolver(...args) {
        // beforeware
        const result = models.Project.fields.memberships.resolver(...args)
        // afterware
        return result
      }
    })
  },
})

Omitting Just One Field

Object spreading supports passing explicit undefined which can be used to quickly filter out things.

objectType({
  name: 'Project',
  definition(t) {
    t.field({
      ...models.Project.fields.memberships
      description: undefined
    })
  },
})

Field Config args Overview

This is the elephant in the room. Lots could be done here. What? A default starting point is asking what could be done with Prisma Client relationship operation findMany:

  1. pagination
  2. ordering
  3. filtering

These are opeations users will want NPP to be able to generate great defaults for.

Field Config args Filtering

Prisma Client allows:

prisma.someModel.findMany({
  where: {
    AND: sub_filter
    OR: sub_filter
    NOT: sub_filter
    [scalar_type_field_name]: scalar_filter
    [relationship_type_field_name]: relationship_filter
  }
})

sub_filter is recursive.

scalar_field_name is some field name on the model.

scalar_filter is a set of operators (specific to the scalar field type) that can be used to express logic on how to filter by the respective field.

The scalar types include: Boolean, String, Int, DateTime. Each has their own operations.

relationship_filter is close to being just a sub_filter but it has additional is and isNot:

{
  AND: sub_filter
  OR: sub_filter
  NOT: sub_filter
  is: sub_filter
  isNot: sub_filter
  [scalar_type_field_name]: scalar_filter
  [relationship_type_field_name]: relationship_filter
}

Before getting into the specifics of how the GraphQL API will represent this there are quite a few architectural issues to sort out. Futhermore the patterns established by OpenCRUD and prior versions of NPP should serve us well.

One issue is that the args will leverage GraphQL Input Object types which means that it is not enough for the user to forward just some field config args. They must also forwrd the Input Objects into their GraphQL schema.

One might wonder if it possible to just have all the types inline and let Nexus figure it out. This would require de-duping arg inline types automatically which could be seen as weakening hunman input validation. Also the circular nature of the input object types here (sub filters, etc.) furhter complicate the situation for inline types. Let's explore other design options.

We could give the user an AST-like representation of the inputs for a given model field, and leave it to them to project.

inputObjectType({
  name: models.Project.fields.memberships.input.where.name,
  definition(t) {
    t.field(models.Project.fields.memberships.input.where.config)
  },
})

inputObjectType({
  name: models.Project.fields.memberships.input.where.filter.name,
  definition(t) {
    t.field(
      models.Project.fields.memberships.input.where.filter.fields.id.config
    )
    t.field(
      models.Project.fields.memberships.input.where.filter.fields.userId.config
    )
    t.field(
      models.Project.fields.memberships.input.where.filter.fields.role.config
    )
  },
})

// ... scalar filters

objectType({
  name: 'Project',
  definition(t) {
    t.field(models.Project.fields.memberships.config)
  },
})

This is verbose already and isn't close to complete yet (e.g. all scalar filters are missing).

There is also a problem with regard to encapsulation. While low-level data for field input is flexible, any customizations will require the resolvers using these input types to be customized too, since the default field resolvers only know about the generated input patterns. This is very low-level and gives little abstraction.

Perhaps the major problem here is that the user has really no way of knowing what inputs will need to be setup to support the default args contained in models.Project.fields.memberships.config.

We could expose the input types that are ready to be passed to makeSchema.

models.Project.fields.memberships.input.filtering.ProjectWhere

// relation filters
models.Project.fields.memberships.input.filtering.ProjectMembershipFilter

// scalar filters
models.Project.fields.memberships.input.filtering.IntFilter
models.Project.fields.memberships.input.filtering.DateTimeFilter

objectType({
  name: 'Project',
  definition(t) {
    t.field(models.Project.fields.memberships.config)
  },
})

This would allow a happy path like so:

makeSchema({
  types: [
    ...models.Project.fields.memberships.input.filtering,
    objectType({
      name: 'Project',
      definition(t) {
        t.field(models.Project.fields.memberships.config)
      },
    }),
  ],
})

One issue here is that there is inconsistent API granularity between model fields and field inputs.

Another, if we want to colocate the input nexus type definitions on the models.Project.fields.memberships namespace then we have to nest the config previously there under .config to avoid namespace clashing.

Another, how would the user decide that some model field shouldn't be able to be filtered upon? E.g. user.password?

Remembering that there are multitude of circular and interdependent input types, manually removing one would require deep knowledge of the internal input data layout, again breaking encapsulation.


This starts to touch on design problem & solutions already explored thoroughly in Unified computed & connect & create CRUD config.

If NP is a Prisma generator, then, what if users could do this:

generator nexus {
  provider = "nexus-prisma"
  models {
    // Declare that client cannot filter by password ever
    User {
      filter {
        fields {
          password = false
        }
      }
    }
    // Declare that client cannot filter projects by membership IDs
    Project {
      fields {
        members {
          filter {
            fields {
              id = false
            }
          }
        }
      }
    }
  }
}

This would allow users to declare things without needing to know how it is achieved in the input object type def graph.

One problem with this is that Prisma has a much less mature toolchain than TS. So not only is the syntax above made up, but getting things like JSDoc, intellisense, and static typing all working for a great DX is not possible today.

In order to move the idea out of Prisma schema and into TS we would need to do generation at nexus refelction time, like NPP does today. Except unlike NPP which only does type generation, NP would be doing runtime generation. If we've going to have to do some generation in TS, then we're going to do all generation there, and unfortunately just skip being a Prisma generator––until the tradeoff of being one makes sense.


Actually, one way to remain being a Prisma generator but not give up TS would be to allow the user to create a TS configuration module that the generator would call at generation time.

//
generator nexusPrisma {
  provider = "nexus-prisma"
}
// nexusPrisma.ts
// somewhere in the user's project
// will be called by the nexusPrisma Prisma generator at generator time

import { settings } from 'nexus-prisma'

settings({
  models: {
    // Declare that client cannot filter by password ever
    User: {
      filter: {
        fields: {
          password: false,
        },
      },
    },
    // Declare that client cannot filter projects by membership IDs
    Project: {
      fields: {
        members: {
          filter: {
            fields: {
              id: false,
            },
          },
        },
      },
    },
  },
})

Another problem with geneating nexus input type defs is that it removes the possibility for flexible field overriding or omission like we saw with model field configuration.

The root issue seems to be that there is no configuration based way to create nexus input object types like there is for configuring fields. In other words this is not possible:

makeShema({
  types: [
    {
      ...models.Project.fields.memberships.input.ProjectMembershipFilter
      fields: {
        ...models.Project.fields.memberships.input.ProjectMembershipFilter.fields,
        id: undefined // omit
      }
    }
  ]
})

But it could be, see graphql-nexus/nexus#638. As pointed out there too we could ship a transformation function if Tim ultimately decided against having that in core:

makeShema({
  types: [
    inputObjectTypeFromConfig({
      ...models.Project.fields.memberships.input.ProjectMembershipFilter
      fields: {
        ...models.Project.fields.memberships.input.ProjectMembershipFilter.fields,
        id: undefined // omit
      }
    })
  ]
})

This solution seems to address problems from multiple former ideas:

One issue here is that there is inconsistent API granularity between model fields and field inputs.

Perhaps the major problem here is that the user has really no way of knowing what inputs will need to be setup to support the default args contained in models.Project.fields.memberships.config.

Another problem with geneating nexus input type defs is that it removes the possibility for flexible field overriding or omission like we saw with model field configuration.

This is verbose already and isn't close to complete yet (e.g. all scalar filters are missing).

One problem with this solution is it has no type safety. That might be acceptable at this low level though.

Whole model configuration

We've seen how whole model fields can be configured, but maybe, we can also have whole model configuration, taking inspiration from what we saw with input object types:

makeShema({
  types: [
    objectTypeFromConfig({
      ...models.Project,
      fields: {
        // ...
      },
    }),
    inputObjectTypeFromConfig({
      ...models.Project.fields.memberships.input.ProjectMembershipFilter,
    }),
  ],
})

I am not sure we can get great type safety here though. What I would love is that the act of ...models.Project would thus specify name field which would in turn cause the whole config to be statically typed. If that turns out not to be possible then maybe we can explore another way like:

makeShema({
  types: [
    models.Project.toNexus({
      fields: {
        // ...
      },
    }),
    models.Project.fields.memberships.input.ProjectMembershipFilter.toNexus({
      // ...
    }),
  ],
})

But this seems to open up API and abstraction discussions which is unwanted. E.g. then one might ask why not this:

makeShema({
  types: [
    models.Project((t) => {
      t.omit('...')
    }),
    models.Project.fields.memberships.input.ProjectMembershipFilter((t) => {
      t.omit('...')
    }),
  ],
})

Design Decisions

Complex Static Types vs Simple Generated Ones

TypeScript is becoming more and more powerful. It is emerging as a little functional programming language. Very complex things can be expressed and amaizng static safety resulting. But there is a cost for end-users which is that intellisense and static type errors become less sensical. This is a big deal because it can be the difference between confusion or sense, minutes vs seconds of debugging (compared to if the types could be simpler, which is usually not the case!).

Put another way, complex static types leak implementation detail to users.

In the general case the tradeoff of more static safety is worth it and that's fine. JSDoc for example can go a long way to mitigating the low-sense-making of compkex static types.

But NPP has the significant benefit of generation. Once the "bridge" (aka. cost) of generation both for the library author and the user workflow is taken on, it does not matter if generation is used for little or a lot. Therefore, it makes the most sense to maximize the benefit of generation since the base workflow cost to the user remains the same either way.

So NPP should maxmize the potential of generation to remove the downside of complex static types in favour of simple ones, only achieveable by generation.

This is a guiding principal for other design decisions. This is not a decision about a specific thing of NPP. This does not mean no complex static types will be used. They will be used as much as they are needed for effective type safety after generation design space has been exhausted.

Prisma Client Reference

We do not need prisma exposed on context. We can make this more gradual. We can by default import and instantiate Prisma Client ourselves, sharing a singleton across all resolvers.

If the user wants to customize their Prisma Client they should be able to configure NPP to either pull it in from the context OR accept a direct reference passed at construction time.

  1. Get from internal import
  2. Get from context object
  3. Get from passed reference

Avoid Namespace Collissions Between Model Fields and Metadata

Problem

Models have fields the user defines:

model Foo {
  id String
}

But models also have metadata:

/// I am a description for the Foo model
model Foo {}

Solutions

One solution is $ prefix for metadata:

models.Project.$description
models.Project.id

Another is more nesting:

models.Project.description
models.Project.fields.id

Traadeoffs

The nice thing about nesting is that it plays better with quick transformations:

// good to go
Object.entries(models.Project.fields)

// need to filter out $-prefix entries...
Object.entries(models.Project).filer(([key]) => !key.match(/^$.*/))

Another nice thing about nesting is that we can embed more JSDoc along the way:

models.Project.fields // <-- We can put some helpful JSDoc here

Note it might seem that nesting would improve the autocomplete experience by focusing the results:

models.Project //.<tab>   <-- no matter how many model fields the metadata of Project will not get lost.

However this is not an advantage because the $ prefix will sparate system vs user namespace.

A downside of nesting is deep property access fatigue.

A downside of $ prefixing is consistentcy or verbosity questions:

models.Project.$description
models.Project.id.description // <-- Not consistent
models.Project.$description
models.Project.id.$description // <-- Consistent, but what about other fields?
models.Project.id.$name // <-- ?
models.Project.id.$type // <-- ?

It might be rather odd to have $ everywhere.

On the other hand, it might be an effective convention to signify what is user namespace versus what is system namespace.

Overall, it seems that that the nested approach avoids uncertain design results from $ prefix while benefiting from being more friendly for transformation logic only interested in the user's namespace of things.

Conclusion

Not sure:

  • Ubiquitous $ prefix for system would feel verbose?
  • Inconsistent $ prefix for system would feel confusing?

Assumptions:

  • Data transformations will more often than not want to deal with the user namespace not mixed with the system namespace

Verdict:

Go nested

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment