graphql-nexus / nexus-plugin-prisma

Deprecated
MIT License
829 stars 118 forks source link

Unified computed & connect & create CRUD config #598

Open jasonkuhrt opened 4 years ago

jasonkuhrt commented 4 years ago

Original Description

Show This proposal comes on the heels of original work by @ssalbdivad on #540 and #566, and by recent discussion between @Weakky and @jasonkuhrt. ## Overview ### Refresh on Computed Inputs #540 made it possible for users to express that certain CRUD inputs should not be part of the schema and should instead have their value provided by the given function. ##### Example before: ```gql mutation { createUser(data: { createdAt: ..., ... }) { ... } } ``` given/then: ```ts mutationType({ definition(t) { t.crud.createUser({ computedInputs: { createdAt: (_root, args, ctx) => ... } }) } }) ``` ```gql mutation { # v--- createdAt no longer here createOne(data: { ... }) { ... } } ``` ### Refresh on "Upfilter Key" #566 has been working toward making it possible to control the `connect` `create` aspects of CRUD inputs on relational data (the original feature idea started out even more general but focused since toward this). The gist is as follows: ##### Example before: ```gql mutation { createUser(data: { mother: { connectable: { id: ... } }, ... }) { ... } } # or mutation { createUser(data: { mother: { creatable: { email: ... } }, ... }) { ... } } # or mutation { createUser(data: { mother: { connectable: { ... }, creatable: { ... } }, ... }) { ... } } ``` given/then: ```ts mutationType({ definition(t) { t.crud.createUser({ mother: { connectable: true } }) } }) ``` ```gql mutation onlyConnect { createUser(data: { mother: { connectable: { id: ... } }, ... }) { ... } } ``` given/then: ```ts mutationType({ definition(t) { t.crud.createUser({ mother: { creatable: true } }) } }) ``` ```gql mutation onlyCreate { createUser(data: { mother: { creatable: { id: ... } }, ... }) { ... } } ``` ## Unifying "Upfilter Key" and Computed Inputs For what its worth, you can view raw code notes by @Weakky and @jasonkuhrt here. It is like raw data, not really consumable information. Again for what its worth.
LiveShare "Code Notes" ```js // // overview // // Layer 1 - global (lowest precedence) nexusPrisma({ crud?: { relations?: { create?: boolean, // default false connect?: boolean, // default false } } }) // nexusPrisma({ // crud?: { // relations?: { // create?: true, // connect?: true, // } // } // }) // Layer 2 - model (spans all CRUD) objectType({ type: 'Toto', crud: { create?: boolean, connect?: boolean, }, definition(t) { // ... } }) // // Layer 2.5 - model field (spans all CRUD) ??????????????????????? // objectType({ // type: 'Foo', // definition(t) { // t.model.toto({ // create?: boolean, // <== creatable: false (not possible) if toto is a required field // connect?: boolean, // }) // } // }) // Layer 3 - CRUD input field (highest precedence) t.model.$all() objectType({ definition(t) { t.model.foo({ filtering: boolean, ordering: boolean, pagination: boolean }) } }) queryType({ definition(t) { t.crud.foo({ pagination: boolean, ordering: boolean | { toto: true } }) } }) queryType({ definition(t) { t.crud.foo({ where: true }) t.crud.foo({ toto: { ordering: false }, field: { ordering: false }, field2: { ordering: false } }) } }) ` mutation { createFoo({ data: { toto: { connectable: { id: "" } }, } }) } ` // --------------------------------------------------- objectType({ name: 'Mother', crud: { creatable: true // <== typegen, changes types in the lower layers }, definition(t) {...} }) type Toto = { mother?: string, [x: string]: any } mutationType({ definition(t) { t.crud.createUser({ inputs: { /// VVVVVVVVV mother: { // <<<<<<<-- dynamic key from PSL /// ^^^^^^ connectable: { }, // Warning: this doesn't make sense }, }, customResolver: ({ root, args, ctx, originalResolver }) => { }, alias: {} }) } }) // gives: ` mutation { createFoo({ data: { age: 11 } }) } ` // --------------------------------------------------- mutationType({ definition(t) { t.crud.createUser({ mother: { computed: () => {}, // doesnt mean anything to use create|connect creatable: { age: { computed: () => 42 } }, connectable: { computed: () => {} } }, }) } }) ` mutation { createFoo({ data: { mother: { creatable: { name: "something", }, connectable: { id: "" | email: "" } } } }) } ` // --------------------------------------------------- // Case study of feature overlap // "I want to remove the field" // -> upfilterKey: Remove one field // -> computedInputs: remove one field AND provide a default value // -> Proposal: Allows to remove a field, and optionally, provide a default value if needed (eg: field is required) // base objectType({ type: 'Foo', definition(t) { t.model.toto() } }) // default ` mutation { createFoo({ toto: { creatable: { ... }, connectable: { ... } } }) { } } ` // with computed input queryType({ definition(t) { t.crud.createFoo({ computedInputs: { toto: ({ root, args, ctx }) => ({ creatable: { name: "default_name" } }) }, }) }) ` mutation { createFoo({ ... }) { } } ` // with upfilter objectType({ type: 'Foo', definition(t) { t.model.toto({ create?: false, // <== creatable: false (not possible) if toto is a required field connect?: false }) } }) // --------------------------------------------------- // Case study of runtime error, UNLESS // Temporarily objectType({ type: 'Foo', definition(t) { t.model.toto({ creatable: false, connectable: false }) } }) queryType({ definition(t) { t.createFoo({ computedInputs: { creatable: () => ({ toto: 'default-VALUE' // <== That is needed because toto is required }) } }) } }) // --------------------------------------------------- // Case study of create/connect default = false // so... // IF e.g. Foo.toto is REQUIRED queryType({ definition(t) { t.createFoo() // error } }) // solution 1 queryType({ definition(t) { t.createFoo({ toto: { connectable: true } }) } }) // solution 2 queryType({ definition(t) { t.createFoo({ computedInputs: { toto: () => ... } }) } }) // notes: // capabilities of the field --vs-- parameters to a capability ```

We thought of colocating Computed Inputs feature as a "capability" like any other available per field. ```ts mutationType({ definition(t) { t.crud.createUser({ inputs: { createdAt: { computed(_root, args, ctx) { return Date.now() } }, mother: { connectable: true } } }) } }) ``` Because it appears to be invalid to have an input field computed _and_ have capabilities configured we could also move the computed up as a function-alternative to capabilities object config: ```ts mutationType({ definition(t) { t.crud.createUser({ inputs: { createdAt(_root, args, ctx) { return Date.now() }, mother: { connectable: true } } }) } }) ``` This function-or-config approach is what we'll stick with in the following examples. ## Details ### Why nest inside `inputs`? Why not this? ```ts mutationType({ definition(t) { t.crud.createUser({ createdAt(_root, args, ctx) { return Date.now() }, mother: { connectable: true } }) } }) ``` Because there are other settings at root, thus model fields could collide with built in settings. We did however think about a future where we prfix all non-model-field-name keys with `$` so that it 1) we can do this, and 2) it generally becomes very clear what parts of config correspond to the data layer models. Example: ```ts mutationType({ definition(t) { t.crud.createUser({ $alias: 'foobar', $type: 'Soup', createdAt(_root, args, ctx) { return Date.now() }, mother: { connectable: true } }) } }) queryType({ definition(t) { t.crud.bars({ $pagination: true, $ordering: { age: true, role: true }, ... }) } }) ``` This will be discussed as its own issue, later. ### Don't we lose API syymetry by having fields in capabilities with query crud? Aren't the following showing API inconsistence? ```ts mutationType({ definition(t) { t.crud.createUser({ foo(_root, args, ctx) { return Date.now() }, bar: { connectable: true } }) } }) queryType({ definition(t) { t.crud.bars({ ordering: { age: true, role: true }, ... }) } }) ``` Maybe not. Maybe it is the difference between capabilities on a field and params to a capabilitiy... Consider the resulting schema: ```gql mutation { # __ Capability for field `bar` # / V createUser({ data: { bar: { connectable: {...} } } }) { ... } } query { # Capability for field `bars` # / | Params to a capability # / v V bars(orderBy: { role: ASC }) { ... } } ``` But... maybe we're hookwinking ourselves here with elaborate made-up semantics... Maybe the following consistentcy really would be the best direction ultimately: ```ts queryType({ definition(t) { t.crud.bars({ inputs: { age: { ordering: true }, role: { ordering: true }, ... } }) } }) ``` But... going back to the API split again, it is split in a way that most closely resembles _visually_/_literally_ the SDL/input args nesting. This seems to suggest a deeper harmony, a positive sign for the API. ### About onion layers Borrowing from #566, this API would have layers like explored there: ```ts // Layer 1 - global (lowest precedence) nexusPrisma({ crud?: { relations?: { create?: boolean, // default false connect?: boolean, // default false } } }) // Layer 2 - model (spans all CRUD) objectType({ type: 'Toto', crud: { relations?: { create?: boolean, connect?: boolean, } }, definition(t) { // ... } }) // Layer 3 - CRUD input field (highest precedence) mutationType({ definition(t) { t.crud.foo({ inputs: { toto: { create?: boolean, connect?: boolean } } }) } }) ``` ### About Encoding the Cascade The following is harmelss but redundant: ```ts nexusPrisma({ crud?: { relations?: { creatable: true, // default false connectable: true, // default false } } }) objectType({ type: 'Toto', crud: { relations?: { creatable: true, // <-- redundant connectable: true, // <-- redundant } }, definition(t) { // ... } }) ``` Redundancy can be user laziness (changed root, not updating codebase) but also user confusion. We don't want to raise an error and block building for something that wouldn't harm runtime. But, we do think it would be great to encode the feedback as a warning. And if we can encode the warning, then we should be to without much effort introduce a codemode to "autofix" the issue. Maybe this can be part of an ESLint plugin, for example. ```ts objectType({ type: 'Toto', crud: { relations?: { creatable: true, // ^^^^^^^^^^^^^ Redundant because create-relations are enabled by default globally connectable: true, // ^^^^^^^^^^^^^ Redundant because connect-relations are enabled by default globally } }, definition(t) { // ... } }) ``` ### About Overriding
This idea was aborted, left here for posterity In the following two examples, do you think either will result in `toto` relation being connectable? ```ts nexusPrisma({ crud?: { relations?: { creatable: false, connectable: true, } } }) mutationType({ definition(t) { t.crud.foo({ toto: { creatable: true, } }) } }) ``` ```ts nexusPrisma({ crud?: { relations?: { creatable: true, connectable: true, } } }) mutationType({ definition(t) { t.crud.foo({ toto: { creatable: true, } }) } }) ``` Maybe not...The thinking being 1) WYSIWYG 2) create/connect capability are atomic, once you begin configuring part of them, its as if you're taking all of it on, and it defaults to off (see point 1). This seems controversial though. By not embracing boolean flip-and-cascade through the layers we remove our ability to create the great linter experience above. setting `false` becomes only possible at the root level. And no other capabilities so far have this "atomic" linking––they all appear to be behaviourly independent peers, which this idea would somehow deceptively compromise...

Current Spec

comment

Related

ssalbdivad commented 4 years ago

@jasonkuhrt @Weakky There's a lot here, so to make sure I have enough time to finish up stage 1, I'm just going to list my first couple thoughts:

  1. This comment referenced the create/connect (both disabled by default, implies exclusivity) approach we had discussed, while I notice above we've reverted to createable/connectable (both enabled by default). Was this intentional?


queryType({
  definition(t) {
    t.crud.bars({
      inputs: {
        age: {
          ordering: true
        },
        role: {
          ordering: true
        },
        ...
      }
    })
  }
})```

I agree this API (with everything organized by field) is more intuitive despite the differences between where the input options manifest themselves.

3.

 queryType({
  definition(t) {
    t.crud.bars({
      $pagination: true,
      $ordering: {
        age: true,
        role: true
      },
      ...
    })
  }
})

I like the idea of removing the inputs level. Since it seems like this will be a breaking change anyways, what if a crud function took up to two parameters, with one representing field configs and the other representing resolver configs:

queryType({
  definition(t) {
    t.crud.bars({
        age: {
          ordering: true
        },
        role: {
          ordering: true
        },
        ...
    }, {alias: "getBars", type: "BarsType"})
  }
})

This approach looks clean and mirrors gql structure. If a single object is passed, we could use its shape to determine whether its an input config or a resolver config.

jasonkuhrt commented 4 years ago

This comment referenced the create/connect (both disabled by default, implies exclusivity) approach we had discussed, while I notice above we've reverted to createable/connectable (both enabled by default). Was this intentional?

Teasing this area of the API apart:

  1. What are the global defaults for connect and create?

    I go back and forth on this one. Working out of the box seems important. Yet being off by default and forcing users to opt in helps (also forcing...) learning, and gives WYSIWYG-esq experience...

  2. What do we call the fields create/connect or connectable/creatable?

    Noun-form emphasizes the concept/config aspect over the action aspect. We have some precedence for this direction: ordering/filtering instead of where/orderBy. I think working with the noun-form gives a better mental model. While I'm talking about this I will note we probably should soon do this term renaming:

        filtering  -> filterable
        ordering   -> orderable
        pagination -> pagable

    Thoughts?

  3. What are the override semantics?

    We've talked a bit about the idea wherein { creatable: true } would imply connectable being false. This is an alternative to a model where connectable would inherit from the level above. I think we came to feel the latter inheritance system is simplest to reason about. I even wrote it out and then aborted in the OP. See the This idea was aborted, left here for posterity part.


I like the idea of removing the inputs level. Since it seems like this will be a breaking change anyways, what if a crud function took up to two parameters, with one representing field configs and the other representing resolver configs:

I sense a lot of bikeshedding potential on this one. For example maybe $ prefix could be for model properties instead (so invert of the idea mentioned in OP).

I suggest for this part, we accept the somewhat unfortunate inputs nesting and discuss sooner rather than later in a new issue this question. We can maybe even keep the two issues within the timeline of a single stable release (each shipping in their own respective pre-releases).

ssalbdivad commented 4 years ago

My thoughts on this are still summarized well by my previous comment. I tried using creatable/connectable and it just didn't click. I feel confident that having a way to opt in to a particular "default relation" that allows the removal of an API layer should be intuitive for new users and flexible for more advanced users. However, perhaps the following API captures the "default relation" idea more intuitively than create/connect:

mutationType({
  definition(t) {
    t.crud.createUser({
        friends: {
          relation: "connect"
        },
        posts: {
          relation: "create"
        },
        ...
    })
  }
})

Possible values would be "create" | "connect" | undefined, with undefined as the default that allows either but keeps the relation layer of the API. Instead of allowing booleans, we could then just use the same config up a level to apply it across all fields:

mutationType({
  definition(t) {
    t.crud.createUser({
        relation: "create",
        ...
    })
  }
})

If we end up using a config like this, relation could also be called relateBy, relatedVia or similar which could make the functionality more intuitive to those seeing it for the first time.

I'm partial to keeping variable names free of prefixes like $ and think the separation of field config and resolver config is clearer as two separate objects. I don't anticipate any difference in complexity of implementation.

I like -able as a suffix in general and for the other cases you mentioned, just not for relations for the reasons specified previously.

jasonkuhrt commented 4 years ago

@ssalbdivad relateBy with union type is having a strong pull on my thoughts this morning :) .

mutationType({
  definition(t) {
    t.crud.createUser({
      friends: {
        relateBy: 'connect' | 'create' | undefined
  //or  relateBy: 'connect' | 'create' | 'connect_or_create'
  //or  relateBy: 'connect' | 'create' | ('connect' | 'create')[]
  //or  relateBy: 'connect' | 'create' | ['connect', 'create'] // tuple

      },
    })
  }
})

@weakky?

Weakky commented 4 years ago

Hey 👋,

I personally still prefer the create|connect: boolean approach for two reasons:

1. Consistency

We already use filtering|ordering|pagination: boolean, we also use t.crud.x({ filtering: { someFieldName: true } }), and not t.crud.x({ filtering: ['someFieldName', 'someOtherFieldName'] }).

Worth mentioning, it's also somehow more consistent with the Prisma Client JS API, which is entirely boolean-based as well now.

2. Evolution of the API

Nexus Prisma V1 used to be based on the union string literals approach to project fields.

queryType({
  definition(t) {
    t.fields(['field1', { name: 'field2', alias: 'somethingElse' }, 'field3'])
  }
})

As soon as we'll want to add options to the connect or create "relations" (or others because there's also delete, 'disconnect', update, 'upsert'...), we'll end up with the same API as Nexus Prisma V1 shown above, which IMO is not beautiful and hard to read.

example:

mutationType({
  definition(t) {
    t.crud.updateUser({
      friends: ['delete', 'create', { relateBy: 'connect', someOtherOption: true }],
      posts: [{ relateBy: 'update', somethingElse: true }]
    })
  }
})

vs

mutationType({
  definition(t) {
    t.crud.updateUser({
      friends: { delete: true, create: true, connect: { someOtherOption: true } },
      posts: { update: { somethingElse: true } }
    })
  }
})

There are others cases where the boolean approach feels more evolvable/more natural. Take this case for example:

Old nexus-prisma API:

queryType({
  definition(t) {
// "default usage": t.fields(['field1', 'field2'])
// Now say you want to allow omitting fields in your API. 👇 Going with an object is already your only api design choice left
    t.fields({ omit: ['field1', 'field2'] }) // Project all fields except field1 and field2
// Notice already how different/inconsistent it is compared to `t.fields(['fields1', 'fields2'])` which is exactly the same as t.fields({ pick: [...] })
// We now have an input type for t.fields that is `object | array<string | object>`
  }
})

vs

boolean-based API (the example below is the old nexus-prisma API converted to a boolean approach):

queryType({
  definition(t) {
    t.fields({ omit: { field1: true, field2: true } })
  }
})
jasonkuhrt commented 4 years ago

@Weakky

we'll end up with the same API as Nexus Prisma V1 shown above

I'm not sure how we end up being forced to go arrays though? Wouldn't it be more like:

mutationType({
  definition(t) {
    t.crud.foo({
      inputs: {
        toto: {
          relateBy: { ... },
        }
      }
    })
  }
})

That said I think we have a lot to lose by ditching the boolean api if so much of the rest uses that pattern. I don't feel comfortable to deviate from it, at least lightly. And you raise a good point about updateFoo case:

mutationType({
  definition(t) {
    t.crud.updateFoo({
      inputs: {
        toto: {
          delete: true,
          update: true,
          relateBy: 'create',
        }
      }
    })
  }
})

I don't think this is nice, nor would it be very nice to have lack of symmetry between create and update I think...

mutationType({
  definition(t) {
    t.crud.updateFoo({
      inputs: {
        toto: {
          delete: true,
          update: true,
          create: true,         // <-- same but different
        }                       //        ^
      }                         //        |
    })                          //        |
    t.crud.createFoo({          //        |
      inputs: {                 //        |
        toto: {                 //        v
          relateBy: 'create',   // <-- same but different
        }
      }
    })
  }
})

I think, but am not sure, that the heart of the debate now is about what e.g. this should mean and if there are better ways the lib could allow user to express it:

mutationType({
  definition(t) {
    t.crud.createFoo({
      inputs: {
        toto: {
          relateBy: true,
        }
      }
    })
  }
})
  1. Always Query.foo(data: { toto: { create: { ... }}) ?
  2. Only if connect disabled globally, then ^ ?

I would be ready to give what is outlined in the OP a shot. (bikeshedding details around naming and nesting ok)

I understand the OP does not make it possible to have a full proof mutually exclusive way to state e.g. "make this crud op be JUST create". But:

  1. Lib can approximate it out-of-box if connect / create is turned off globally by default.
  2. User can approximate it (regardless of what lib does out-of-box) if they turn off create / connect globally

Assuming I am correct this is the primary point of debate, I am not sure how strongly everyone feels about their current positions. For myself, I feel @Weakky has a point that

Also welcome input from @colinmcd94 who created https://github.com/prisma-labs/nexus-prisma/issues/95 and @maartenraes who created https://github.com/prisma-labs/nexus-prisma/issues/518.

jasonkuhrt commented 4 years ago

I realized a wrinkle in my OP, maybe some of you already saw this issue.

Take this computed input (using the new API from OP):

mutationType({
  definition(t) { 
    t.crud.createUser({
      inputs: {
        toto(_root, args, ctx) {
          return 'foobar' // just for illustration
        },
      }
    })  
  }
})

What will the resolver args be?

resolve(_root, args){
  args.data.toto.create === 'foobar'    // ?
  args.data.toto.connect === 'foobar'   // ?
  args.data.toto === 'foobar'           // ?  <-- probably this...
}

The simplest way forward I see is that a computed input field which is a relation should simplify to no connect/create nesting.

What "parts of the system" need to care about this:

  1. Users (if they are using #550)
  2. Internally (how we invoke Prisma Client)
maartenraes commented 4 years ago

So as requested, some input about my experience with relationship fiddling and the problems we've faced and tried to solve. There's a lot to talk about here, but to give some context about our thoughts and where we come from.

As mentioned in https://github.com/prisma-labs/nexus-prisma/issues/518 we had the need in an ongoing project to disable certain creates and updates, some one level deep others multiple nested levels deep.

We've implemented this on the objectType method as following

objectType({
    name: "Department",
    definition(t) {
        // all blabla and other fields
        // Relations
        addRelations(t, [
            { name: "organisation", required: true },
            { name: "domain" },
            { name: "address", type: "DepartmentAddress", nestedCreate: true, nestedUpdate: true },
            { name: "locations", list: true },
            { name: "activities", list: true },
            { name: "contact", type: "DepartmentContact", cascadeOnDelete: true, nestedCreate: true, nestedUpdate: true },
        ]);
    },
});

Our mutation use the objectType definition to resolve the provided model in a correct way, and nexus also shows the correct documentation schema on which relations you can get/create/update/disconnect etc.

What was important for us is that we wanted a way to customize our relations on a per field level to enable security customization with a middleware later on. Important to note is that our implementation is a purely opt-in for nestedCreateand nestedUpdate. That said it should be easy to configure on a global level what default behavior should be.

I'm certainly aware that our implementation throws away all API specs, guidelines and best practices that all of you are very passionate about, but still we can take away a few things from our experience.

The create|connect: boolean approach is for new developers in a team the most readable and understandable. Combined with some good autosuggestion from our IDE's in Typescript this makes for a pretty enjoyable developer experience. A new dev on our project didn't need much time to comprehend and extend our current implementation because it was readable, the logic behind the middleware is a bit less readable but that doesn't matter since a normal user wouldn't get in touch with the Prisma codebase.

I thus follow @Weakky his point that consistency and readability are a plus with a boolean implementation vs an array implementation.

In my honest opinion tho I have a lot less authority in this discussion than everyone here, so feel free to give some thoughts about mine.

ssalbdivad commented 4 years ago

@maartenraes Thanks so much for taking the time to write this up. It's awesome to see others interested in nexus-prisma's unsolved problems and that type of input (no pun intended :bowtie:) helps a ton by giving me something other than my own intuitions to go off of :heart:

I recently updated https://github.com/prisma-labs/nexus-prisma/pull/566 with an informal write up of the API I've been working on for handling computed inputs and relations. I'll quote that comment here for reference (with some slight edits for brevity):

I've written up some new summary documentation for the inputs config API I built, which currently supports configuration at the plugin and resolver levels. I've tried to build this in a way that should allow it to be expanded to eventually handle other scopes (e.g. model) and configs like ordering, filtering, etc.:

Transform your Prisma Inputs into a Clean GraphQL API

// Create an object that maps field names to configs.
const inputsConfig = {
  /*
    Removes Prisma fields named "user" from affected input types
    and infers their values from context at runtime.
  */
  user: {
    computeFrom: ({ args, ctx, info }) => ({ connect: { id: ctx.userId } }),
  },
  /*
    Removes the "create/connect" input type for fields named "blog" and
    the corresponding layer of the API, replacing it with a connect-only type.
  */
  blog: {
    relateBy: 'connect',
  },
}

/*
  This config can be applied globally, affecting fields across all input types
  that are passed to the config.
*/
nexusPrismaPlugin({
  inputs: inputsConfig,
  /*
    Sets the default relateBy value globally. If unset, the default value
    is "any", which preserves the "create/connect" layer from Prisma's API.
    "relateBy" values passed to individual inputs always have a higher
    precedence than those passed directly to a config like this one,
    even if the config-level relateBy has a more narrow scope.
  */
  relateBy: 'create',
})

/*
  The same config can be applied to a resolver. This will override the global
  config and will not affect input types outside of the resolver to which
  it is passed.
*/
mutationType({
  definition(t) {
    t.crud.createOnePost({
      inputs: {
        blog: {
          /*
            This overrides the relateBy value for the 'blog' field that was
            passed globally.
          */
          relateBy: 'create',
        },
      },
      /* 
        Override the default relateBy for the resolver. For example,
        if the global default is 'create', you might pass 'any' to allow
        both create and connect by default.
      */
      relateBy: 'any',
    })
  },
})

Without the above inputs config:

mutation createOnePost {
  createOnePost(
    data: {
      title: "Automatically generate clean APIs with nexus-prisma!"
      blog: {
        create: {
          name: "Redo your expectations"
          url: "https://redo.qa"
          user: { connect: { id: 1 } }
        }
      }
    }
    user: { connect: { id: 1 } }
  )
}

With inputs config:

mutation createOnePost {
  createOnePost(
    data: {
      title: "Automatically generate clean APIs with nexus-prisma!"
      blog: { name: "Redo your expectations", url: "https://redo.qa" }
    }
  )
}

After a lot of deliberation, I didn't end up moving forward with the relation toggle API because while building and consuming the new relations config, the easiest way to think about its effect was twofold:

  1. Create a default relation type within some scope
  2. Remove the API layer required to specify a relation type (currently, that API layer is an input object with create and connect keys, although it could have more in the future)

(1) is useful by itself for refining which relations you want to expose within some scope, but (2) is what makes the feature so compelling because it fundamentally improves your schema. In the quoted example, you can see how much cleaner an API call looks with these features, and I don't think the example is particularly contrived.

If we want to allow the removal of optional fields (like create and connect, but also others that have nothing to do with prisma relations), that could be a useful feature on its own. However, since the most important part of this relations-specific feature is (2), I'd offer that the config is more intuitive as a string representing the default relation as opposed to a map of relation types to booleans enabling/disabling them.

maartenraes commented 4 years ago

@ssalbdivad How would it look for an array of blogs and for an updateResolver? Currently we an an input type like this one. But with omitting create/connect/update noun it doesn't seem very intuitive.

mutation createOnePost {
  createOnePost(
    data: {
      title: "Automatically generate clean APIs with nexus-prisma!"
      blogs: { 
          create: [{ 
              name: "Redo your expectations", 
              url: "https://redo.qa"
          }], 
          update: [{ 
              id: "550e8400-e29b-41d4-a716-446655440000",
              name: "Follow your ballistic trajectory", 
              url: "https://ballistix.digital"
          }],
          disconnect: [{ 
              id: "550e8400-e29b-41d4-a716-446655440001"
          }],
      }
   )
}
ssalbdivad commented 4 years ago

@maartenraes I've seen a nested update like that so I'm not totally sure how it would work, however relateBy only affects inputTypes with two fields, create and connect, by replacing the entire input type with the input type of the "relateBy" field. Creating multiple blogs would look like this:

mutation createOnePost {
  createOnePost(
    data: {
      title: "Automatically generate clean APIs with nexus-prisma!"
      blogs: [
            { name: "Redo your expectations", url: "https://redo.qa" },
            { name: "Follow your ballistic trajectory", url: "https://ballistix.digital"}
     ]
    }
  )
}

To me, that's only unintuitive is if its viewed through the Prisma lens in which all relation types are expected to be available. In an API that was designed from scratch, I would expect rather than leveraging intermediate layers like create and connect to clarify the meaning of user input, most developers would prefer using aliases to make resolver/field names more descriptive, e.g. createLinkedBlogs instead of blogs.

maartenraes commented 4 years ago

@ssalbdivad Agree with the more descriptive naming like createLinkedBlogs, my example was not entirely complete since I've mistakenly put createOnePostinstead of updateOnePost as name of the mutation.

I don't know what the default behavior is for an updateMany with the current prisma-client-js but we've implemented functionality that you can mix and match nested updates, nested creates and nested removes. Removes as in we want to remove the relationship between the two objects without removing one of the objects, thus calling it disconnect.

In your current spec there's no support to mix and match unless you do something vague like assuming you provide an extra id field for updating a nested relationship. Examples: Assume you currently already have a post with a single linked blogpost with id 123

mutation updateOnePost {
  updateOnePost(
    data: {
      title: "Automatically generate clean APIs with nexus-prisma!"
      blogs: [
            // by providing the id you could expect this one to update the current blog
            { id: "123", name: "Redo your expectations with an extra parameter", url: "https://redo.qa" }, 
           // this one does not have an id so it would create a new one
            { name: "Follow your ballistic trajectory", url: "https://ballistix.digital"}
     ]
    }
  )
}

The only thing missing here is the ability to remove a relationship, currently that's possible with the disconnect I think.

ssalbdivad commented 4 years ago

@maartenraes I love the idea of having a fluid API for performing multiple nested relations. I'd worry a bit that it's not obvious what's going on with blogs at a glance, and as you mentioned it's not obvious how to distinguish between a connect and a disconnect (both would only require a unique field). Perhaps your original solution is the best way to clarify that? If it were a TS API we could use tuples like [RelationType, GetInput<ModelName, RelationType>] to keep things flatter but I don't think there's a good way to do that in GraphQL.

Weakky commented 4 years ago

I've spent some time with @Jasonkuhrt refining our proposals in this issue. This new proposal tries to address the problems we found in the original one.

Glossary

Operation:

Refers to the Prisma operations exposed in GraphQL mutations. Some are top level (t.crud.<...>) while others are nested (connect). They are: create, delete, update, upsert, set, connect, disconnect.

Issues with relateBy

Initially explained in one of my comments above, we remain unconvinced by the relateBy API. Once we tackle the other operations we strongly believe that relateBy will not evolve properly.

Let's say a user wants to expose create, delete and update only. The only consistent way we see relateBy evolving is:

import { mutationType } from 'nexus-prisma'

mutationType({
  definition(t) {
    t.crud.updateOneA({
      inputs: {
        someField: {
          relateBy: ['create', 'delete', 'update']
        }
      }
    })
  }
})

Which, if we ever need to add more options to the different operations, will have to evolve like this to remain consistent:

import { mutationType } from 'nexus-prisma'

mutationType({
  definition(t) {
    t.crud.updateOneA({
      inputs: {
        someField: {
          relateBy: [{ name: 'create', someOtherOption: true }, 'delete', 'update']
        }
      }
    })
  }
})

👆 We were and stay against that.

Reminder of the boolean-based API

We received criticism in regards to the boolean-based API, saying that it was too verbose and not ergonomic.

We believe this is true only if we do not give it the right default behavior. That behavior, should be the very same as the current API for pagination (and slighly differently but equally: filtering and ordering.)

The pagination API work like so:

Example:

t.crud.createOneBlog({
  inputs: {
    posts: {
      connect: true // This is equivalent to `relateBy: 'connect'`
    }
  },
})

If a global default was set: eg: { inputs: { ...: create: true } }, the local scope takes precedence and overrides it. (This is also AFAIK how the defaults work in nexus)

This API allows a clean evolvability and stays consistent with the current API.

Issue with computeFrom

The feature is more or less based on "field name" string matching (just like computedInputs). This brings some problems very quickly.

Here's an example:

Let's consider the following simplified Prisma Schema:

model A {
  someField    Int
  relationToB  B
}
model B {
  someField    String
}

And let's customise the createOneA mutation:

import { mutationType } from 'nexus-prisma'

mutationType({
  definition(t) {
    t.crud.createOneA({
      inputs: {
        someField: {
          computeFrom: () => 1337
        }
      }
    })
  }
})

Given the schema above, this will result in a subtle runtime error which is that B.someField is of type String and it's going to receive an Int of value 1337. This is because inputs.someField.computeFrom is not tied to any type. As your schema evolve this behavior can bring lots of unintended side-effects.

It's important to note that the problem described below is very likely already present in the current computedInputs feature. It's our fault to not have seen it before, but it's a fairly important bug for us.

There does not seem to be an easy way to fix that bug while keeping its current behavior (transformation done on string matching at any arbitrary depth-level in the graph).

Refinements

We have continued to refine our direction in a way that we think solves computedInputs use-cases in a safe way.

1. Global CRUD configuration

Goal: Globally set the performable mutative operations.

The global configuration should not be tied to any input field name. This is required because there is nothing safe we can do at that level based on just a string-typed (no typegen) field name. Any number of GraphQL object types in the schema could have a same-named field. Therefore, the only configuration should be enabling or disabling mutative operations:

nexusPrismaPlugin({
  crud: {
   create: boolean,
   connect: boolean,
   // ...
  }
})

2. Model-level CRUD configuration

Goal: Globally define the performable mutative operations on a particular model, or model's fields, in the GraphQL API

Overrides: The global level

Because the GraphQL API is a graph the only way to safely perform global transformation on a node is to "apply" the transformations from the node itself.

We therefore came up with the following API:

objectType({
  name: 'ModelName',
  crud: {
    [operationName]: true
    // or
    [operationName]: {
      computed({ ctx, edge: { from, nullable, list, args }}) {
        /* ... */ 
      }
    }
  },
  definition(t) {
    t.model.someScalarField({
      [operationName]: true
      // or
      [operationName]: {
        computed({ ctx, edge: { from, nullable, list, args }}) {
          /* ... */ 
        }
      }
    })
  }
});

Here are some concrete examples:

Only allow a user model to be created
mutationType({
  definition(t) {
    t.crud.createOneBlog()
  }
})

objectType({
  name: 'Blog',
  definition(t) {
    t.model.id()
    t.model.users()
  }
})

objectType({
  name: "User",
  crud: {
    create: true
  },
  definition(t) {
    t.model.id();
    t.model.age();
  }
});

What the above crud configuration says is that wherever we encounter a field that refers to the type User in a mutation input object type, only the create operation will be performable (if it is at all)

The (super simplified) outputted GraphQL schema would be the following:

type Blog {
  id: Int
  users: User[]
}

type User {
  id: Int
  age: Int
}

type Mutation {
  createOneBlog(data: CreateBlogInput): Blog
}

type CreateBlogInput {
  users: CreateOrConnectUserInput
}

type CreateOrConnectUserInput {
  create: CreateUserInput | CreateUserInput[] ## Only create is available here
}

type CreateUserInput {
  ...
}
Remove user createdWithUserAgent field from the GraphQL input object type graph but keep it in the resolver args for passing into Prisma Client and thus into the data layer where it is required information.
mutationType({
  definition(t) {
    t.crud.createOneBlog()
  }
})

objectType({
  name: "User",
  definition(t) {
    t.model.id();
    t.model.age();
    t.model.createdWithUserAgent({
      crud: {
        create: {
          computed({ ctx }) {
            return ctx.request.headers.userAgent
          }
        }
      }
    })
  }
});

3. Operation Level CRUD configuration

Goal: Customize the GraphQL input object for a particular mutation.

Overrides: Global level & Model level

At this level, and to keep the API simple, the only safe transformations possible is to perform them on the fields of the type itself and not for the relations (just like for the filtering API)

Let's take the same example as before.

Considering the following simplified Prisma Schema:

model A {
  someField    Int
  relationToB  B
}
model B {
  someField    String
}

And given the following customised createOneA mutation:

import { mutationType } from 'nexus-prisma'

mutationType({
  definition(t) {
    t.crud.createOneA({
      inputs: {
        someField: {
          computed() { return 1337 }
        }
      }
    })
  }
})

The (super simplified) outputted GraphQL schema would be the following:

type Mutation {
  createOneA(data: CreateAInput): A
}

type A {
  someField:  Int
  relationToB: B
}

type B {
  someField: String
}

type CreateAInput {
  relationToB: CreateOrConnectBInput
}

type CreateOrConnectBInput {
  connect: ConnectBInput | ConnectBInput[]
  create: CreateBInput | CreateBInput[]
}

type ConnectBInput {
  id: Int
}

type CreateBInput {
  someField: String ## B.someField IS NOT transformed
}

You can see above that B.someField was not transformed because the transformation applied is scoped to the type A only.

Conclusion

On our side, we hope to advance on this spec in Q2. We don't know for sure yet though.

@ssalbdivad if you update your PR to a subset of this spec we'd be happy to collaborate on getting it merged.

Spec

This is a TS precise-ish representation of our proposal. ```ts // 1. Global Level interface GlobalCRUDSettings { crud: { create: boolean delete: boolean update: boolean upsert: boolean set: boolean connect: boolean disconnect: boolean pagination: boolean ordering: boolean filtering: boolean } } // 2. Model Level // Model Itself type ComputedCreate<>Edge = { from: string // constrained with typegen nullable: boolean // constrained with typegen list: boolean // constrained with typegen args: Record } | { /*...*/ } type ComputedCreate<>Return = { kind: string // constrained with typegen data: any // constrained with typegen } | { /*...*/ } type ComputedCreate<> = (edge: ComputedCreate<>Edge, args: Create<>Args, ctx: Context) => ComputedCreate<>Return type ComputedUpdate<> = (edge: ComputedUpdate<>Edge, args: Update<>Args, ctx: Context) => ComputedUpdate<>Return type ComputedUpsert<> = (edge: ComputedUpsert<>Edge, args: Upsert<>Args, ctx: Context) => ComputedUpsert<>Return type ComputedSet<> = (edge: ComputedSet<>Edge, args: Set<>Args, ctx: Context) => ComputedSet<>Return type ComputedConnect<> = (edge: ComputedConnect<>Edge, args: Connect<>Args, ctx: Context) => ComputedConnect<>Return type ComputedDisconnect<> = (edge: ComputedDisconnect<>Edge, args: Disconnect<>Args, ctx: Context) => ComputedDisconnect<>Return type ComputedDelete<> = (edge: ComputedDelete<>Edge, args: Delete<>Args, ctx: Context) => ComputedDelete<>Return type ModelCRUDSettings<> = { crud: { create: boolean | { computed: ComputedCreate<> } delete: boolean | { computed: ComputedDelete<> } update: boolean | { computed: ComputedUpdate<> } upsert: boolean | { computed: ComputedUpsert<> } set: boolean | { computed: ComputedSet<> } connect: boolean | { computed: ComputedConnect<> } disconnect: boolean | { computed: ComputedDisconnect<> } pagination: boolean | { /* ... */ } ordering: boolean | { /* ... */ } filtering: boolean | { /* ... */ } } } // Model Scalar Field // Note: The computed func signatures are the same as at operation level. // The difference is how these are inherited. // todo, set type ModelScalarFieldCRUDSettings<><> = { crud: { create: boolean | { computed: OperationComputedCreate<><> } update: boolean | { computed: OperationComputedUpdate<><> } upsert: boolean | { computed: OperationComputedUpsert<><> } set: boolean | { computed: OperationComputedSet<><> } } } // 3. Operation Level type OperationComputedCreate<><> = (args: Create<>Args, ctx: Context) => OperationComputedCreate<><>Return type OperationComputedUpdate<><> = (args: Update<>Args, ctx: Context) => OperationComputedUpdate<><>Return type OperationComputedUpsert<><> = (args: Upsert<>Args, ctx: Context) => OperationComputedUpsert<><>Return type OperationCreate<>Settings = { inputs?: { scalarField: { // actual field names from typegen computed: OperationComputedCreate<><> } relationField: // actual field names from typegen | { computed: OperationComputedCreate<><> } | { create?: boolean connect?: boolean } > } type OperationUpdate<>Settings = { inputs?: { scalarField: { // actual field names from typegen computed: OperationComputedUpdate<><> } relationField: // actual field names from typegen | { computed: OperationComputedUpdate<><> } | { create?: boolean connect?: boolean set?: boolean disconnect?: boolean delete?: boolean update?: boolean updateMany?: boolean deleteMany?: boolean upsert?: boolean } > } type OperationUpsert<>Settings = { updateInputs?: // see OperationUpdate above createInputs?: // see OperationCreate above } ```
ssalbdivad commented 4 years ago

@Weakky @jasonkuhrt Thanks for the work you've done on this. I think there is some really interesting discussion to be had around, for example, type safety for return values of computeFrom functions. Clearly, there are trade-offs between type safety and flexibility here that are worth exploring. However, there is no need for that work to take place before merging https://github.com/prisma-labs/nexus-prisma/pull/566 for the functionality from that PR to be useful.

More broadly, there seems to be a lot of confusion around the relateBy API. @Weakky gives this example of how relateBy would be used:

import { mutationType } from 'nexus-prisma'

mutationType({
  definition(t) {
    t.crud.updateOneA({
      inputs: {
        someField: {
          relateBy: ['create', 'delete', 'update']
        }
      }
    })
  }
})

I absolutely understand why you'd have a problem with that API. I'd like to again clarify that relateBy accepts a single string value (not an array) representing the default relation for a particular field. To be able to remove the inputObject that is needed to specify which relation to use from the API altogether, we need to have exactly one allowed relation, therefore string, not string[], is the correct data type to specify that relation. I wrote the same during our discussion on https://github.com/prisma-labs/nexus-prisma/pull/566:

relateBy allows the user to specify that only one type of nested relation is allowed for a particular field, and in doing that allows the user to skip specifying the relation type explicitly when they provide an input object

And that is what is reflected in the example I gave:

Transform your Prisma Inputs into a Clean GraphQL API

// Create an object that maps field names to configs.
const inputsConfig = {
  /*
    Removes Prisma fields named "user" from affected input types
    and infers their values from context at runtime.
  */
  user: {
    computeFrom: ({ args, ctx, info }) => ({ connect: { id: ctx.userId } }),
  },
  /*
    Removes the "create/connect" input type for fields named "blog" and
    the corresponding layer of the API, replacing it with a connect-only type.
  */
  blog: {
    relateBy: 'connect',
  },
}

/*
  This config can be applied globally, affecting fields across all input types
  that are passed to the config.
*/
nexusPrismaPlugin({
  inputs: inputsConfig,
  /*
    Sets the default relateBy value globally. If unset, the default value
    is "any", which preserves the "create/connect" layer from Prisma's API.
    "relateBy" values passed to individual inputs always have a higher
    precedence than those passed directly to a config like this one,
    even if the config-level relateBy has a more narrow scope.
  */
  relateBy: 'create',
})

/*
  The same config can be applied to a resolver. This will override the global
  config and will not affect input types outside of the resolver to which
  it is passed.
*/
mutationType({
  definition(t) {
    t.crud.createOnePost({
      inputs: {
        blog: {
          /*
            This overrides the relateBy value for the 'blog' field that was
            passed globally.
          */
          relateBy: 'create',
        },
      },
      /* 
        Override the default relateBy for the resolver. For example,
        if the global default is 'create', you might pass 'any' to allow
        both create and connect by default.
      */
      relateBy: 'any',
    })
  },
})

Without the above inputs config:

mutation createOnePost {
  createOnePost(
    data: {
      title: "Automatically generate clean APIs with nexus-prisma!"
      blog: {
        create: {
          name: "Redo your expectations"
          url: "https://redo.qa"
          user: { connect: { id: 1 } }
        }
      }
    }
    user: { connect: { id: 1 } }
  )
}

With inputs config:

mutation createOnePost {
  createOnePost(
    data: {
      title: "Automatically generate clean APIs with nexus-prisma!"
      blog: { name: "Redo your expectations", url: "https://redo.qa" }
    }
  )
}

Earlier in this thread I delineated between a future feature that could enable/disable individual relations or optional fields in general and relateBy, which is designed to specify a default relation and actually remove a layer of the API:

After a lot of deliberation, I didn't end up moving forward with the relation toggle API because while building and consuming the new relations config, the easiest way to think about its effect was twofold:

  1. Create a default relation type within some scope
  2. Remove the API layer required to specify a relation type (currently, that API layer is an input object with create and connect keys, although it could have more in the future)

(1) is useful by itself for refining which relations you want to expose within some scope, but (2) is what makes the feature so compelling because it fundamentally improves your schema. In the quoted example, you can see how much cleaner an API call looks with these features, and I don't think the example is particularly contrived.

If we want to allow the removal of optional fields (like create and connect, but also others that have nothing to do with prisma relations), that could be a useful feature on its own. However, since the most important part of this relations-specific feature is (2), I'd offer that the config is more intuitive as a string representing the default relation as opposed to a map of relation types to booleans enabling/disabling them.

To reiterate, I think it would be great to build a feature that could enable/disable individual relations or optional fields in general. It would be supplemental to relateBy, which would accept only a single string, not a replacement for it, because we should not be suddenly removing layers of an API when exactly one relation remains enabled. That would be unintuitive and difficult to control, particularly given that the config can be passed at multiple levels. A feature like relateBy that doesn't just set a default relation but actually remove the inputObject with relation types as field names should remain insulated from a feature that would individually toggle relation types. I would recommend creating a new issue to track creating the relation toggle @Weakky describes.

If any of that is not clear, please let me know.

jasonkuhrt commented 4 years ago

because we should not be suddenly removing layers of an API when exactly one relation remains enabled. That would be unintuitive and difficult to control, particularly given that the config can be passed at multiple levels.

I understand that it is suggested for relateBy to perform this schema "compaction".

I also understand your concern that it being automatic with the "feature flag boolean" API is unintuitive. Presumably, though we haven't stated explicitly yet, I guess it is being assumed that when only one operation is possible at a "layer", then the schema would automatically be compacted.

As an aside I also appreciate it was literally the original goal of your PR. It is an important feature.

I agree with your criticism. But I don't see the solution in relateBy.

I would like to see compaction dealt with as its own concept that does not overlap with other concepts. It is a general idea that shows up in the schema in various places, not just the 'create' | 'connect' dichotomy.

For example

I say much more because not only is it about cases today where models with e.g. one unique field lead to compactable cases, but all the cases to come once the API can be further customized.

Maybe an API author in the future can configure what args are allowed in a given NullableStringFilter to just contains, thus opening up the option for compaction in that case. Today the fields of that input object are:

  equals: String
  not: String
  in: [String!]
  notIn: [String!]
  lt: String
  lte: String
  gt: String
  gte: String
  contains: String
  startsWith: String
  endsWith: String

I think compaction is a general issue where connect | create is merely one instance. I am not convinced that the API should go down the path of coupling these concerns.

I think compaction is "controversial" and thus should default to "off". Compaction adds bespoke exceptions into an API that actually reduce predictability and take away from patterns. Its very situational and needs to be opt in, at the API author's judgement.

Another reason compaction is dangerous is that it is backwards incompatible. As an API author, if I remove connect but leave create I can always bring back connect without breaking the API for my clients. However once I compact, I lose that ability. There are some similarities here to why using null as much as possible in an API is considered a good practice in some circles of the GraphQL community.

So I think its pretty clear compaction requires consideration in its own right, decoupled from other decision points.

I am thinking that there be flag to opt into schema compaction. Some examples:

// Operation Level
mutationType({
  definition(t) {
    t.crud.createOneBlog({
      compaction: true,         // span all inputs
      inputs: {
        author: {
          connect: true
        }
      }
    })
  }
})
mutationType({
  definition(t) {
    t.crud.createOneBlog({
      inputs: {
        editors: {
          connect: true,
          compaction: false,     // per input
        },
        author: {
          connect: true,
          compaction: true,
        }
      }
    })
  }
})
objectType({
  name: 'Foo',
  definition(t) {
    t.model.bars({ ordering: { id: true, $compaction: true } })
    //                                   ^^^^^^^^^^^^^^^^^ 
    // For args on a relational field
    // $ prefix b/c unpredictable model field names
  }
})
queryType({
  definition(t) {
    t.crud.bars({ filtering: { title: true, $compaction: true } })
    //                                      ^^^^^^^^^^^^^^^^^ 
    // For args on a relational list field
    // query entrypoint $ prefix b/c unpredictable model field names
  }
})
ssalbdivad commented 4 years ago

@jasonkuhrt The original implementation of this feature was upfilterKey, which did exactly what you're describing: allowed the user to pass in the name of any field they want "compacted," provided other fields on the inputType are optional. I'd be happy to move back to that API so it could be used in other cases like filtering. As I've said, the benefits of removing a layer of the API are, to me, the most significant aspect of this feature and what allows the transformation of a DB interaction tool to a GraphQL schema that doesn't make its users want to cry.

I'd be happy to revert back to the more flexible "upfilter" implementation if you'd like and could rename it to "compact." However, the original feature was upfilterKey, not enable/disable relation types, so especially given the scope of current changes from https://github.com/prisma-labs/nexus-prisma/pull/566, I'd like to create a new issue for that work.

Do we have any ETA on a resolution to the DMMF import issue that I ran into last time?

jasonkuhrt commented 4 years ago

I'd be happy to revert back to the more flexible "upfilter" implementation if you'd like and could rename it to "compact." However, the original feature was upfilterKey, not enable/disable relation types, so especially given the scope of current changes from #566, I'd like to create a new issue for that work.

To achieve your original goal IIUC, and based on the recent refinements we published today, two things would need to get implemented:

  1. compaction flag
  2. create/connect flags

Then your original goal gets expressed e.g. like:

mutationType({
  definition(t) {
    t.crud.createOneBlog({
      inputs: {
        editors: {
          connect: true,    // or create: true
          compact: true
        }
      }
    })
  }
})

There are a LOT of other features that we've spec'd out but focusing on JUST ^ (forget changes to computedFrom, inheritance, etc. etc. etc.) would ultimately achieve your original upfilter goal correct?

If so, then to answer your question, sort of, yes "revert back" to what you had, so long as it matches the interface and semantics roughly of that. No objections from us. Then we can continue iterating forward, adding the inheritance, so on.

Do we have any ETA on a resolution to the DMMF import issue that I ran into last time?

I believe the issue is a simple one and I'll try to take time to look tonight. If it proves non-obvious then ETA becomes uncertain. I'll confirm one way or another. I'm optimistic though.

jasonkuhrt commented 4 years ago

For the name detail of compact vs compaction you seemed to have gravitated to compact. Sounds good to me.

ssalbdivad commented 4 years ago

@jasonkuhrt Thanks for your flexibility. upfilterKey was originally a string, not a boolean. The example I gave in my first comment on https://github.com/prisma-labs/nexus-prisma/pull/566 was this:

t.crud.createOneTest({upfilterKey: "create"})

mutationType({
  definition(t) {
    t.crud.createOneBlog({
      inputs: {
        editors: {
          connect: true,    // or create: true
          compact: true
        }
      }
    })
  }
})

It's hard for me to see how a boolean-based interface like that would work. Assume that by default, all relation types are enabled globally. Since to "compact," you need exactly one enabled relation type, you could easily end up needing to disable all the others:

mutationType({
  definition(t) {
    t.crud.createOneBlog({
      inputs: {
        editors: {
          create: false,
          delete: false,
          update: false,
          upsert: false,
          set: false,
          disconnect: false,
          compact: true
        }
      }
    })
  }
})

This would be even more difficult to keep track of as we add more types to the hierarchy. Compare that to a single string:

mutationType({
  definition(t) {
    t.crud.createOneBlog({
      inputs: {
        editors: {
          // The name of the nested field to "upfilter" 
          compact: "connect"
        }
      }
    })
  }
})

I can restrict the value of that string if you'd like, but if you want to preserve the flexibility of the original upfilterKey for cases like filtering, I could just leave it as a string value. If that field is encountered on an inputObject in which not all other fields are optional, the upfilterKey would be ignored (perhaps with a warning?). Let me know your thoughts.

jasonkuhrt commented 4 years ago

you could easily end up needing to disable all the others:

When user specifies flags, there is no inheritance with the levels above. Everything becomes disabled. So the user would not need to disable all the others. This is what @Weakky was also referring to when he said:

If a value is passed, everything gets disabled besides what you set to true.

For another example of this kind of zero-inheritance system in the existing [surrounding] API see https://nexus.js.org/docs/plugin-connection#modifying-arguments. (the opt-in inheritance is interesting in its own right too, but we think its for future discussions).

To make this concrete again, here's an example with the update mutation (which has many more nested operations than create mutation):

mutationType({
  definition(t) {
    t.crud.updateOneBlog({
      inputs: {
        editors: {
          disconnect: true,
          compact: true
          // create: false,       // implied, user does not have to specify
          // delete: false,       // implied, user does not have to specify
          // update: false,       // implied, user does not have to specify
          // upsert: false,       // implied, user does not have to specify
          // set: false,          // implied, user does not have to specify
        }
      }
    })
  }
})

I am not ecstatic with the fact that this can happen:

mutationType({
  definition(t) {
    t.crud.updateOneBlog({
      inputs: {
        editors: {
          disconnect: true,
          create: true
          compact: true           // useless
        }
      }
    })
  }
})

I can see three collaborative ways to mitigate:

  1. Good jsDoc that embeds usage docs
  2. Unions that statically forbid it (at the expense of more complicated intellisense/autocomplete)
  3. [Beautiful] runtime warnings

But actually your suggestion is an interesting alternative since:

  1. compact will be false by default
  2. compact will only ever make sense when fields in the layer (speaking as generic as possible) are reduced to exactly 1

So in the same way we have the intention to make computed a mutually exclusive union from all the other fields in a layer we could do that for compact as well. To be clear I mean this (roughly, should make the point):

    relationField:         // actual field names from typegen
      | {
          compact: 'create' | 'connect' | 'set' | ...
        }
      | {
          computed: OperationComputedUpdate<<ModelName>><<Field>>
        }
      | {
          create?: boolean
          connect?: boolean
          set?: boolean
          disconnect?: boolean
          delete?: boolean
          update?: boolean
          updateMany?: boolean
          deleteMany?: boolean
          upsert?: boolean
        }

I actually really like that. And I think we can keep this as a general pattern. Here's how it could look with ordering:

queryType({
  definition(t) {
    t.crud.bars({ filtering: { $compact: 'title' } })
  }
})

vs my previous sketch:

queryType({
  definition(t) {
    t.crud.bars({ filtering: { title: true, $compaction: true } })
  }
})

I would like to hear from @Weakky on Monday of course but this is looking pretty solid to me!

Aside: Once we enter $ territory its almost like Mongo query DSL or something where we're throwing transformations/operators into the mix.

ssalbdivad commented 4 years ago

@jasonkuhrt

compact will only ever make sense when fields in the layer (speaking as generic as possible) are reduced to exactly 1

Yes, that is what I've been trying to say this whole time :bowtie:

I think some of the other problems you're describing will be resolved as we transition to the input-first config we've implemented for this feature:

mutationType({
  definition(t) {
    t.crud.updateOneBlog({
      inputs: {
        editors: {
          filtering: true,
          compact: "where"
        }
      }
    })
  }
})
RafaelKr commented 4 years ago

Hi. As a silent follower of the discussion in this and the issue #566 I really like the direction where it is heading.

I played a little bit around with some TypeScript typings and for me I had the best autocompletion results, both in IntelliJ and VS Code, with this schema:

type RelationCreationTypes = 'create' | 'connect' | 'set' | 'update' | 'updateMany' | 'upsert'
type RelationRemovalTypes = 'disconnect' | 'delete' | 'deleteMany'
type RelationTypes = RelationCreationTypes | RelationRemovalTypes

type RelationMethodCompact = {
    compact: RelationCreationTypes
    computed?: undefined
    specific?: undefined
}

type RelationMethodComputed = {
    computed: () => any //OperationComputedUpdate<<ModelName>><<Field>>
    compact?: undefined
    specific?: undefined
}

type RelationMethodSpecific = {
    specific: {
        [P in RelationTypes]?: boolean
    }
    compact?: undefined
    computed?: undefined
}

type RelationMethod =
    | RelationMethodCompact
    | RelationMethodComputed
    | RelationMethodSpecific

const compact: RelationMethod = {
    compact: 'connect',
}

// mutationType({
//  definition(t) {
//      t.crud.updateOneBlog({
//          inputs: {
//              editors: {
//                  relationMethod: {
//                      compact: 'connect',
//                  }
//              }
//          }
//      })
//  }
// })

const computed: RelationMethod = {
    computed: () => {}
}

// mutationType({
//  definition(t) {
//      t.crud.updateOneBlog({
//          inputs: {
//              editors: {
//                  relationMethod: {
//                      computed: () => // ...
//                  }
//              }
//          }
//      })
//  }
// })

const specific: RelationMethod = {
    specific: {
        connect: true,
        update: true,
    }
}

// mutationType({
//  definition(t) {
//      t.crud.updateOneBlog({
//          inputs: {
//              editors: {
//                  relationMethod: {
//                      specific: {
//                          connect: true,
//                          update: true,
//                      }
//                  }
//              }
//          }
//      })
//  }
// })

As you can see I defined the new property relationMethod where you can only set one key (the methods compact, computed or specific) at the same time. I think this makes it easier for new users of nexus-prisma, as they can explicitly see the options they have.

@ssalbdivad I also tried to set type RelationMethod = RelationCreationTypes | RelationMethodComputed | RelationMethodSpecific, the problem with it was, that both code editors only suggested the string values for RelationCreationTypes. So if you don't know about computed and specific you can't see it just by using the autocompletion feature. That's why IMO it would be better to explicitly define compact as key in the object. I see the following advantages with it:

  1. More explicit
  2. Better autocompletion experience
  3. Easier for new users of nexus-prisma

But that's just my two cents, maybe you also want to play around with the proposed schema and try to find some ways to improve it even further. I'm happy to get a feedback on that :)

jasonkuhrt commented 4 years ago

I just realized today that TS doesn't (yet) have great excess property checking. Example. Only since 3.5 was there any on discriminated unions even.

What this means is that the invariant I sketched before will, for now, unfortunately, not be statically encoded:

    relationField:         // actual field names from typegen
      | {
          compact: 'create' | 'connect' | 'set' | ...
        }
      | {
          computed: OperationComputedUpdate<<ModelName>><<Field>>
        }
      | {
          create?: boolean
          connect?: boolean
          set?: boolean
          disconnect?: boolean
          delete?: boolean
          update?: boolean
          updateMany?: boolean
          deleteMany?: boolean
          upsert?: boolean
        }

User can still do, but it is incorrect:

relationField: {
  compact: 'blah',
  computed(){ return 'blah' } 
}

We will need runtime feedback for users. Anyways we would want that to be JS friendly but now it would more of a priority.

I am in favour of designing the API assuming exact types could be enforced, and then awaiting TS featureset to catch up.

Once resolved, it should make allow VSCode to present the autocomplete options here like how it does with overloaded functions, as a list the user can toggle through (I would like to see improvements on that UI itself, but, different issue).

There is the TS Language Service Plugin route, and how it could tap into autocomplete and provide the ideal intellisense that we wish TS gave us.

This would be only practical at the framework level where it would be bundled and used automatically.

@RafaelKr you present something we can do today about the latter issue without any further work by us. But the nesting is quite a tradeoff I feel. For example somewhere in this issue near the top is some talk about de-nesting inputs by adopting a $-prefix standard (because model field names cannot start with $). But, I think this is something we can try, and keep iterating on until we get right. I would be interested to go the TS Language Service route, optimizing usage of this library for framework context. But until that idea is proven to be viable, I like giving yours a shot.

My take on the naming would be:

    relationField:         // actual field names from typegen
      | {
          computed: OperationComputedUpdate<<ModelName>><<Field>>
        }
      | {
          compactCapability: 'create' | 'connect' | 'set' | ...
        }
      | {
          capabilities: {
              create?: boolean
              connect?: boolean
              set?: boolean
              disconnect?: boolean
              delete?: boolean
              update?: boolean
              updateMany?: boolean
              deleteMany?: boolean
              upsert?: boolean
            }
        }
RafaelKr commented 4 years ago

If there's a better way using the TS Language Service it should be used, of course.

And I like the naming you suggested.

RafaelKr commented 4 years ago

I just noticed that I'd given a wrong example for the last one which I called "specific".

I've updated it although I like your example more. Given that I did understand correctly how you meant it. Would your full example with other options look like this then?

mutationType({
    definition(t) {
        t.crud.updateOneBlog({
            filtering: {
                originalAuthor: true,
                editors: true,
            },
            ordering: {
                date: true,
            },
            inputs: {
                editors: { // editors is the "relationField"
                    capabilities: {
                        connect: true,
                        update: true,
                    },
                },
            },
        })
    },
})
jasonkuhrt commented 4 years ago

@RafaelKr Yup exactly, this would be near term.

Longer term I'm imagining it might be more like:

mutationType({
    definition(t) {
        t.crud.updateOneBlog({
            editors: { // editors is the "relationField"
                connect: true,
                update: true,
            },
            $filtering: {
                originalAuthor: true,
                editors: true,
            },
            $ordering: {
                $compact: 'date' // if api author wants it
            },
        })
    },
})

We'll see :)

jasonkuhrt commented 4 years ago

Current Spec

Level 1: Defaults

Example ```ts nexusPrismaPlugin({ capabilities: { create: true, connect: false, }, }); ```


Goal Set the default performable mutative operations

Solution Toggle mutative operations in root plugin config.

Spec

interface CapabilityDefaults {
  capabilities?: {
    create?: boolean;
    delete?: boolean;
    update?: boolean;
    upsert?: boolean;
    set?: boolean;
    connect?: boolean;
    disconnect?: boolean;
    pagination?: boolean;
    ordering?: boolean;
    filtering?: boolean;
  };
}

Level 2: Model

Example Restrict User model to only being creatable ```ts mutationType({ definition(t) { t.crud.createOneBlog(); }, }); objectType({ name: "Blog", definition(t) { t.model.id(); t.model.users(); }, }); objectType({ name: "User", capabilities: { create: true, }, definition(t) { t.model.id(); t.model.age(); }, }); ``` What the above `crud` configuration says is that **wherever** we encounter **a field that refers to the type `User`** in a mutation input object type, **only the `create` operation** will be performable (if it is at all). The (super simplified) outputted GraphQL schema would be the following: ```graphql type Blog { id: Int users: User[] } type User { id: Int age: Int } type Mutation { createOneBlog(data: CreateBlogInput): Blog } type CreateBlogInput { users: CreateOrConnectUserInput } type CreateOrConnectUserInput { create: CreateUserInput | CreateUserInput[] ## Only create is available here } type CreateUserInput { ... } ```


Goal Set the performable mutative operations on a particular model

Overrides Level 1

Solution Configuration on objectType.

Spec

type ModelCRUDSettings<<ModelName>> = {
  capabilities?: {
    create?:     boolean | { computed: ComputedCreate<<ModelName>> }
    delete?:     boolean | { computed: ComputedDelete<<ModelName>> }
    update?:     boolean | { computed: ComputedUpdate<<ModelName>> }
    upsert?:     boolean | { computed: ComputedUpsert<<ModelName>> }
    set?:        boolean | { computed: ComputedSet<<ModelName>> }
    connect?:    boolean | { computed: ComputedConnect<<ModelName>> }
    disconnect?: boolean | { computed: ComputedDisconnect<<ModelName>> }
    pagination?: boolean | { /* todo */ }
    ordering?:   boolean | { /* todo */ }
    filtering?:  boolean | { /* todo */ }
  }
}

type ComputedCreate<<ModelName>>     = (edge: ComputedCreate<<ModelName>>Edge, args: Create<<ModelName>>Args, ctx: Context) => ComputedCreate<<ModelName>>Return
type ComputedUpdate<<ModelName>>     = (edge: ComputedUpdate<<ModelName>>Edge, args: Update<<ModelName>>Args, ctx: Context) => ComputedUpdate<<ModelName>>Return
type ComputedUpsert<<ModelName>>     = (edge: ComputedUpsert<<ModelName>>Edge, args: Upsert<<ModelName>>Args, ctx: Context) => ComputedUpsert<<ModelName>>Return
type ComputedSet<<ModelName>>        = (edge: ComputedSet<<ModelName>>Edge, args: Set<<ModelName>>Args, ctx: Context) => ComputedSet<<ModelName>>Return
type ComputedConnect<<ModelName>>    = (edge: ComputedConnect<<ModelName>>Edge, args: Connect<<ModelName>>Args, ctx: Context) => ComputedConnect<<ModelName>>Return
type ComputedDisconnect<<ModelName>> = (edge: ComputedDisconnect<<ModelName>>Edge, args: Disconnect<<ModelName>>Args, ctx: Context) => ComputedDisconnect<<ModelName>>Return
type ComputedDelete<<ModelName>>     = (edge: ComputedDelete<<ModelName>>Edge, args: Delete<<ModelName>>Args, ctx: Context) => ComputedDelete<<ModelName>>Return

type ComputedCreate<<ModelName>>Edge =
  | { /*...*/ }                 // typegen: all object fields pointing here
  | {                           // example:
      from: string              // typegen: name of the object whose field points here
      nullable: boolean         // typegen: is the field pointing here nullable?
      list: boolean             // typegen: is the field pointing here a list?
      args: Record<string,any>  // typegen: the args for the field pointing here
    }

type ComputedCreate<<ModelName>>Return =
  | { /*...*/ }                 // typegen: All the return types of fields pointing here
  | {                           // example:
      kind: string              // typegen: name of object whose field points here
      data: *                   // typegen: return data (backing type) accepted by the field pointing here
    }


Level 3: Model Scalar Field

Example Remove a Model Field (that the data layer requires) from the create operation ```ts mutationType({ definition(t) { t.crud.createOneBlog(); }, }); objectType({ name: "User", definition(t) { t.model.id(); t.model.age(); t.model.createdWithUserAgent({ capabilities: { create: { computed({ ctx }) { return ctx.request.headers.userAgent; }, }, }, }); }, }); ```


Goal Make a model's field computed

Overrides Level 1 & 2

Solution Configuration on t.model.

// Note: The computed func signatures are the same as at Level 4
//       The difference is how these are inherited.

type ModelScalarFieldCRUDSettings<<ModelName>><<FieldName>> = {
  capabilities?: {
    create?:     boolean | { computed: OperationComputedCreate<<ModelName>><<FieldName>> }
    update?:     boolean | { computed: OperationComputedUpdate<<ModelName>><<FieldName>> }
    upsert?:     boolean | { computed: OperationComputedUpsert<<ModelName>><<FieldName>> }
    set?:        boolean | { computed: OperationComputedSet<<ModelName>><<FieldName>> }
  }
}


Level 4. Operation (aka. root field, entrypoint)

Example ```ts mutationType({ definition(t) { t.crud.createOneFoo({ someField: { computed() { return 1337; }, }, }); }, }); ```


Goal: Customize one entrypoint

Solution: Configuration on t.crud

Overrides Level 1 & 2 & 3

Spec

type OperationComputedCreate<<ModelName>><<Field>> = (args: Create<<ModelName>>Args, ctx: Context) => OperationComputedCreate<<ModelName>><<Field>>Return
type OperationComputedUpdate<<ModelName>><<Field>> = (args: Update<<ModelName>>Args, ctx: Context) => OperationComputedUpdate<<ModelName>><<Field>>Return
type OperationComputedUpsert<<ModelName>><<Field>> = (args: Upsert<<ModelName>>Args, ctx: Context) => OperationComputedUpsert<<ModelName>><<Field>>Return

type OperationCreate<<ModelName>>Settings = {
  <<ScalarField>>?: {                              // actual field names from typegen
    computed: OperationComputedCreate<<ModelName>><<Field>>
  }
  <<RelationField>>?:                              // actual field names from typegen
    | {
        computed: OperationComputedCreate<<ModelName>><<Field>>
      }
    | {
        capabilities: {
          create?: boolean
          connect?: boolean
        }
      }
    | {
        compactCapability:
          | 'create'
          | 'connect'
     }
}

type OperationUpdate<<ModelName>>Settings = {
  <<ScalarField>>?: { // actual field names from typegen
    computed: OperationComputedUpdate<<ModelName>><<Field>>
  }
  <<RelationField>>?: // actual field names from typegen
    | {
        computed: OperationComputedUpdate<<ModelName>><<Field>>
      }
    | {
        capabilities: {
          create?: boolean
          connect?: boolean
          set?: boolean
          disconnect?: boolean
          delete?: boolean
          update?: boolean
          updateMany?: boolean
          deleteMany?: boolean
          upsert?: boolean
        }
      }
    | {
        compactCapability:
          | 'create'
          | 'connect'
          | 'set'
          | 'disconnect'
          | 'delete'
          | 'update'
          | 'updateMany'
          | 'deleteMany'
          | 'upsert'
      }
}

type OperationUpsert<<ModelName>>Settings = {
  updateInputs?:  // see OperationUpdate above
  createInputs?:  // see OperationCreate above
}
Akxe commented 4 years ago

I know it might not be the point, but curiosity killed the cat...

Why is the crud property on t and not on the objectType that we want to generate SQL getters/setters?

const Blog = objectType({
  name: "Blog",
  definition(t) {
    t.model.id();
    t.model.users();
  },
});

const User = objectType({
  name: "User",
  capabilities: {
    create: true,
  },
  definition(t) {
    t.model.id();
    t.model.age();
  },
});

mutationType({
  definition(t) {
    Blog.crud.createOne()
    // Or, if `t` must be somehow used
    //Blog.crud.createOne(t)
  },
});
jasonkuhrt commented 4 years ago

@Akxe a bit off topic so feel free to open a discussion about it. Its technically possible. But the current approach makes use of the benefits of being a "dynamic field builder". One benefit being that if other nexus plugins are in use that add schema field level config, t.crud would benefit (#584).

durdenx commented 3 years ago

Any ETA for this ? It's an essential feature for us to migrate from prisma 1.

lvauvillier commented 3 years ago

Hi @jasonkuhrt, is the spec still relevant?

I start to look at how to implement the new api:

Using the onObjectDefinition hook in a second pass is will definitely add a lot of complexity as we need all level values to properly compute inheritance.

Any thoughts?

jasonkuhrt commented 3 years ago

Hey @lvauvillier its the best and most clear spec this project currently has. But no ETA right now for this.