aws-amplify / amplify-category-api

The AWS Amplify CLI is a toolchain for simplifying serverless web and mobile development. This plugin provides functionality for the API category, allowing for the creation and management of GraphQL and REST based backends for your amplify project.
https://docs.amplify.aws/
Apache License 2.0
90 stars 76 forks source link

Does the GraphQL Transformer support interfaces? #453

Open hisham opened 6 years ago

hisham commented 6 years ago

Which Category is your question related to? graphql-transformer

Quick question: I'm curious if the transformer supports interfaces as documented here: https://docs.aws.amazon.com/appsync/latest/devguide/interfaces-and-unions.html

Currently creating the schema for my app, so I guess I will find out soon! :)

hisham commented 6 years ago

GraphQL with interfaces compiled with no issues so closing this for now.

hisham commented 6 years ago

Actually, based on the example at https://docs.aws.amazon.com/appsync/latest/devguide/interfaces-and-unions.html, how would you use graphql transformer to model this?

How I'd think of it is that there would be one "Event" table and so that would be the actual "@model" and different types of objects in it as follows. Since you're using "__typename" in the db table to distinguish which type of object and item is, then this is akin to single table inheritance in the RDBMS world. Correct? But doesn't look like amplify cli supports this since I get this error when I run the below schema:

Schema Errors:
Directive "model" may not be used on INTERFACE.
GraphQL request (13:16)
12: 
13: interface Event @model {
                   ^
interface Event @model {
        id: ID!
        name : String!
        startsAt: String
        endsAt: String
        venue: Venue
        minAgeRestriction: Int
}

type Concert implements Event {
    id: ID!
    name: String!
    startsAt: String
    endsAt: String
    venue: Venue
    minAgeRestriction: Int
    performingBand: String
}

type Festival implements Event {
    id: ID!
    name: String!
    startsAt: String
    endsAt: String
    venue: Venue
    minAgeRestriction: Int
    performers: [String]
}

type Conference implements Event {
    id: ID!
    name: String!
    startsAt: String
    endsAt: String
    venue: Venue
    minAgeRestriction: Int
    speakers: [String]
    workshops: [String]
}
hisham commented 6 years ago

It doesn't make sense to mark Conference, Festival, and Concert here as "@model" because then 3 tables would be created and the whole point of "interface" would be lost. But please correct me if I am wrong...

mikeparisstuff commented 6 years ago

Hey these are all interesting points. The short answer is that the transform allows you to define interfaces and use them however you want but it does not support @model directives on interface types. This could 100% be a feature request that when using @model on an interface type, then we will store all object types that implement that interface in the same table using the __typename as the hash key and some uuid as the sort key but this has implications (esp. around how @connection would work) that should be discussed.

"the whole point of "interface" would be lost."

I don't agree with this as the point of interfaces is to express that multiple object types share a common set of attributes at the API level. This makes no implication about where or how your data is stored and thus it is really irrelevant if its 3 tables or 1. In your particular use case you want 1 table, but someone else might want 3 and both are totally reasonable uses of interface types.

hisham commented 6 years ago

Hi @mikeparisstuff thanks for the detailed response.

Fair point that in some cases people might want 3 tables instead of 1. And now with your support of custom queries and planned support for custom resolvers I think one would be able to write a query to get all Events even if they are stored in 3 different tables.

My particular use case is more of where the interface is a "User" type and the particular implementations can be something like "Doctor", "Patient", and "Staff" where these users can all do things like login but have very different views and permissions in the app.

I think it would make sense to have all Users, regardless of type, be in one table. You can argue I can use Cognito groups to differentiate between users but I think my functionality here goes beyond permissions and more of also the type of data stored for each user type and the relationships they have with other models in the system. Also DynamoDB's NoSql guide recommends to minimize the number of tables so this seems like a good case where only one table is needed rather than 3.

I am interested in what implications you are hinting at around how @connection would work if I go with the above plan. Any pitfalls I should be aware of or best practice recommendations would be appreciated. Currently I am only using the graphql transformer to see how amplify does things and my real appsync setup is done by hand since we need custom resolvers and ability to connect to a dynamo database and lambda methods in a different region than where appsync is hosted.

mikeparisstuff commented 6 years ago

This makes sense. Unfortunately, I'm not sure this is going to be supported in the transform out of the box yet but you could write your own transformer that does just this. You can read more about how to do that here: https://github.com/aws-amplify/amplify-cli/blob/master/how-to-write-a-transformer.md

donaldarmstrong commented 5 years ago

@mikeparisstuff Adding a type where @model on an interface would be preferred, using the model from @hisham

type Ticket @model { id: ID! event: Event @connection }

erik-induro commented 5 years ago

I believe that this is related so I'm asking it here but I can open another question if that would be more appropriate.

I am trying to create a connection between two tables. I would like the child table to contain different types of objects that implement a similar interface. This issue indicates that I can't keep all of the child types in the same table. I tried to keep them in separate tables but then I don't think that @connection will support that.

I tried something similar to the following:

type Container @model {
  ...
  things: [Thing] @connection(name: "Things")
}

interface Thing {
  ...
  container: Container!
}

type Thing1 implements Thing @model {
  ...
  container: Container! @connection(name: "Things")
}

type Thing2 implements Thing @model {
  ...
  container: Container! @connection(name: "Things")
}

I get "InvalidDirectiveError: Could not find an object type named Thing". I think that the right way to solve this is to allow multiple types in one table rather than allowing @connection to reference multiple tables. Thoughts?

flybayer commented 5 years ago

I'm needing to do the same thing as Erik. Basically, a many-to-many relationship between a type and an interface.

Related: aws-amplify/amplify-cli#91

davekiss commented 5 years ago

Also just ran into this error –– sounds like the @model transformer on interfaces could be a great addition to the CLI

j-r-t commented 5 years ago

Really need interface to support @model and the implementing types to use a single table. For example if we have a type of Post and a TextPost or ImagePost I would like all of the Posts to be contained in a single table.

nateiler commented 5 years ago

Also interested in interface support. My scenario involves a 'FieldType' interface in which they all share the same DynamoDB table. I've implemented this through my own resolvers, but when CLI v3 came out I found myself going back and updating all of the custom resolver logic to mirror the new @auth directive enhancements.

chinmaygadre commented 4 years ago

+1 for feature My scenario involves a single "Person" type but variations (e.g. Singer, Dancer etc.) where each may have a specific relation to another object. BUT I would like all "persons" to be stored in a single table. The rationale is that a person - say Jane - can be a singer as well as dancer I just need one "person" object stored with different tags/aspects.

igorkosta commented 4 years ago

@hisham would you, please, share with us how you implemented the interface @model behaviour?

hisham commented 4 years ago

I wrote a node script that modifies the cloudformation json files amplify cli generates (e.g. ConnectionStack.json and the model stack jsons). This script is run before any amplify push. Let's say if you have interface that 3 models implement. Amplify CLI will want to create 3 tables and data sources for these. So my script creates 1 table as the data source and connects the 3 models to that data source.

It's a temporary workaround, but has been running fine and survived various amplify cli updates. Hopefully an official solution comes out at some point.

igorkosta commented 4 years ago

@hisham could you, maybe, share your script on https://gist.github.com/? I would really appreciate it. I have the same use case and thought about using the ENUM to differentiate between different user types. Btw. do you use amplify mock and if you do, does the script also creates 1 table locally?

Thank you very much in advance!!!

hisham commented 4 years ago

Sure here you go: https://gist.github.com/hisham/93ac6fcbe66f4a346d32681dc83e5ce2/revisions

The part that is not in the gist is you'll have to create a User.json and UserTable.json nested cloudformation stack (for the table you want the models to actually be stored in + appsync connections). These you will have to create manually through a custom nested stack, which amplify cli supports. They are similar tables and setup to what amplify cli creates for @model decorated types so basically copy the json amplify cli creates for one of your models that implement the interface and change some names in it to what your interface is called.

Now that amplify cli supports custom transformers, there's probably a better solution out there via graphql transformers. Would be nice if someone builds that at some point! :)

igorkosta commented 4 years ago

@hisham Thank you very much 👍

hisham commented 4 years ago

No problem. To answer your other question, I haven't tried amplify mock yet for appsync. I did try it once or twice but ran into an issue and never looked into it deeply. It's on my todo list. I don't think it's related to my script.

wtrevena commented 4 years ago

Sure here you go: https://gist.github.com/hisham/93ac6fcbe66f4a346d32681dc83e5ce2/revisions

The part that is not in the gist is you'll have to create a User.json and UserTable.json nested cloudformation stack (for the table you want the models to actually be stored in + appsync connections). These you will have to create manually through a custom nested stack, which amplify cli supports. They are similar tables and setup to what amplify cli creates for @model decorated types so basically copy the json amplify cli creates for one of your models that implement the interface and change some names in it to what your interface is called.

Now that amplify cli supports custom transformers, there's probably a better solution out there via graphql transformers. Would be nice if someone builds that at some point! :)

Thank you very much @hisham , this is exactly what we are looking for but we are struggling to reproduce this technique. We have not been successful in correctly recreating the "ConnectionStack.json" file referenced in your "modifyAmplifyCF.js" as we were expecting to see @connection used somewhere in the example in the header:

 interface User {
   id: ID!
  email: AWSEmail!
 }

 type Doctor implements User @model {
   id: ID!
  email: AWSEmail
  licenseNumber: Int
 }

 type Patient implements User @model {
  id: ID!
  email: AWSEmail
  healthcardNumber: Int
 }

Additionally, we are still trying to figure out what should be in the different stacks ("User.json", "UserTable.json", "Doctor.json", and "Patient.json") as we hypothesized that "UserTable.json" would be equivalent to an amplify created @model template, but that significant changes would be necessary to produce a "User.json" interface as well as "Doctor.json" and "Patient.json" interface implementing types belonging to the "UserTable".

If you have time and if you are comfortable with sharing this information, we would greatly appreciate it if you could share:

  1. The correctly modified "cloudformation-template.json" for reference
  2. The schema.graphql files in ./amplify/backend/api//build and in ./amplify/backend/api/ (we think this is the root of our problem)
  3. And if you have time, the "User.json", "UserTable.json", "ConnectionStack.json", and either a "Doctor.json" or "Patient.json" file

We greatly appreciate any help, and we are very grateful that you have found a workaround for this issue as many sleepless nights have led us here.

To provide a little more context, our overall objective is to produce a single DynamoDB table for our application with a similar structure using an interface (or ideally a Union but we have been unable to find working examples utilizing amplify), and then build batch mutation, query, and getitem pipeline resolvers to satisfy our access patterns.

hisham commented 4 years ago

Hi @wtrevena it's been a while since I implemented this so it's not fresh on my mind and I'm unable to share the source.

You're right that the example I provided does not have a @connection in the model. I guess if your models have connections (for example, a doctor can be connected to a prescriptions table), then you would have a ConnectionStack.json.

But if your model does not have any @connections then I guess no need to modify a Connection Stack.

Doctor.json and Patient.json are auto-generated and would be in your build/stacks folder. You would take one of these, cp it into a User.json in your custom stacks folder (stacks/) and find and replace "Patient" with "User". Alternativelly create a temporary User @model then take the auto-generated User.json and move it to your custom stacks folder, then delete the @model.

UserTable.json is not really equivalent the amplify created @model template. It is equivalent to one of the AWS::DynamoDB::Table resources in your cloudformation-template.json (e.g. you would have auto generated DoctorTable and PatientTable dynamodb table resources in your cloudformation-template.json using my example in the gist snippet). It's basically one of these but extracted in its own json.

ianmartorell commented 4 years ago

Interfaces don't work even when they're used as nested properties without an @model directive. 😢

It will add the property to the return type of queries, but the input types for mutations are missing it. It seems like the feature is there but only half-way. Is this a bug?

So this doesn't work:

type Page @model {
  id: ID!
  ...
  blocks: [Block]
}

interface Block {
  id: ID!
}

type TextBlock implements Block {
  id: ID!
  ...
}

type ButtonBlock implements Block {
  id: ID!
  ...
}

Same result with unions:

type Page @model {
  id: ID!
  ...
  blocks: [Block]
}

type TextBlock  {
  id: ID!
  ...
}

type ButtonBlock {
  id: ID!
  ...
}

union Block = TextBlock | ButtonBlock

This is what the generated schema looks like:

type Page {
  id: ID!
  ...
  blocks: [Block]
}

input CreatePageInput {
  id: ID
  # Missing `blocks` field
}

input UpdatePageInput {
  id: ID
  # Missing `blocks` field
}
GonzAVic commented 4 years ago

hey @ianmartorell did you found how to use the interface without the @model directive?

ianmartorell commented 4 years ago

hey @ianmartorell did you found how to use the interface without the @model directive?

No, what I did was use a discriminator field like this:

enum BlockType {
  TEXT
  BUTTON
  ...
}

type Block {
  id: ID!
  type: BlockType!
  ... # fields from all block types
}
renegoretzka commented 3 years ago

I am looking forward to support single-table-design for interface @model transformer. I hope this will be supported in the near feature. Thanks a ton!

erobertshaw commented 3 years ago

hey @ianmartorell did you found how to use the interface without the @model directive?

No, what I did was use a discriminator field like this:

enum BlockType {
  TEXT
  BUTTON
  ...
}

type Block {
  id: ID!
  type: BlockType!
  ... # fields from all block types
}

Does anyone have a better solution than this that allows for "type" checking of required data?

Ideally this would do:

type Block {
   id: ID!
   type: BlockType!
   fooRequiredByTypeText: Something! @connection
   barRequiredByTypeBlock: SomethingElse! @connection
 }

Single table inheritance is what I'm looking for to use a ruby on rails term.

loganpowell commented 3 years ago

I arrived here for the same reason as @erik-induro. I am trying to use an interface, with @model on each of that interface's implementations, but am getting this error:

InvalidDirectiveError: Could not find an object type named Node.

Here's my (pseudo)schema:


type Edge @model {
    id: ID!
    type: EdgeType!
    to: Node! @connection(name: "NodeEdges")
    from: Node! @connection(name: "NodeEdges")
}

interface Node {
    id: ID!
    edges: [Edge] @connection(name: "NodeEdges")
}

type PublicNode implements Node
    @model
{
    # Standard
    id: ID!
    edges: [Edge] @connection(name: "NodeEdges")
    # Custom
    content: String
}

type PrivateNode implements Node
    @model
{
    # Standard
    id: ID!
    edges: [Edge] @connection(name: "NodeEdges")
    # Custom
    status: Status
}

I'm currently using amplify mock api, so I'm not sure if it's a local dev issue or not, just thought I'd ask.

Addendum

I just want to explain why supporting @model on interface/union types would help me. I'm trying to create two classes of let's say an article:

  1. A published article (publicly viewable: Auth = public)
  2. A draft article (private: Auth = author only)

I would like to be able to control view access to the public on such a node. Is there another way than to use two separate @models with different @auth directives?

loganpowell commented 3 years ago

This makes sense. Unfortunately, I'm not sure this is going to be supported in the transform out of the box yet but you could write your own transformer that does just this. You can read more about how to do that here: https://github.com/aws-amplify/amplify-cli/blob/master/how-to-write-a-transformer.md

@mikeparisstuff

I'd be interested in going down this route. Are there any in-depth trainings/tutorials for doing so? The docs that you've linked to here are a great introduction, but I'm not smart enough to figure out how to build a plugin from the ground-up using them...

mrpatrick commented 2 years ago

I have the same use case as @loganpowell - I am also not sure how the custom transformer handles this based on the link provided, but also not sure why this wouldn't just be supported out of the box?

loganpowell commented 2 years ago

I have tried to kludge this with the native Amplify CLI functionality by using a private version of the models I'd like to protect and a public version that can be read by all. However, after using this in our prototype we came up with the following shortcomings:

  1. When toggling a private asset to a public one, we have to (carefully) delete the private ones and create the public ones, which may cause issues if we somehow loose client connection in the process and the private assets are no longer in memory where they can be reestablished.
  2. In a many:many connection, this becomes even more complex using only the native tools (@manyToMany). I.e., If I want to create a private node that cannot be reached that has private assets, I have the same problem as number 1 above, but also in a nested field (so the parent also has this issue) leading to a lot of complex client boilerplate that has tripped us up a number of times so far.

The benefit of allowing the @model to live on an interface, IMHO, would allow us to keep the data in the same table, requiring only the change of a sort key (or primary key on a GSI) for that entry. If this was married with the new permissions scoping features in a way that enabled entity permission control based on that key/field, we could have a much simpler client developer experience.

However, I would be happy to implement my own transformer which enabled this if the docs provided such an example.

Edit: Perhaps allowing an enum to provide the authentication rules might allow us to bypass much of the concerns that modeled interfaces address in the context of the preceding issues, something like this:


enum PermissionType  {
  PUBLIC @auth( rules: [
      { allow: public, operations: [ read ] },
      { allow: groups, groups: ["Admins", "Editors"] }
    ])
  PRIVATE @auth( rules: [
      { allow: groups, groups: ["Admins", "Editors"] }
   ])
}

type Resource @model {
  id: ID! @primaryKey
  status: PermissionType! @index(name: "Resources_by_Status", queryField: "resourcesByStatus")
  name: String!
}

type Node @model {
  id: ID! @primaryKey
  status: PermissionType! @index(name: "Nodes_by_Status", queryField: "nodesByStatus")
  resources: [Resource] @hasMany(indexName: "Resources_by_Node", fields: ["id"])
  edges: [Edge] @manyToMany(relationName: "EdgeNode")
}

type Edge @model {
  id: ID! @primaryKey
  nodes: [Node] @manyToMany(relationName: "EdgeNode")
}
loganpowell commented 2 years ago

Just left a note along these lines to https://github.com/aws-amplify/amplify-cli/issues/1043

josefaidt commented 2 years ago

related aws-amplify/amplify-category-api#314

johnf commented 2 years ago

@alharris-at As an FYI I'm getting one notification per issue I'm watching as you move them to the new repositories Is the monorepo being split up? Will make it much easier to contribute and trace issues if so

alharris-at commented 2 years ago

@johnf Not entirely, but we are separating the API category (Rest + GraphQL functionality) as well as all transformers into a standalone repo reflecting the complexity and focus we plan to pay into these on their own moving forward.

morepe commented 1 year ago

The last entry here is over a year but from my tests it still does not work to use union or interfaces. Even on types without "@model" it did not work for me.

adeldueast commented 11 months ago

bump+

JMorr96 commented 9 months ago

Hopefully one of these days this will be supported.

Sircrab commented 6 months ago

This is still an issue, I cannot use interfaces because the Inputs will not be generated for the schemas, so it doesn't matter if we can generate the queries, we cannot create any field that uses an interface, no matter if it uses @model or not.