Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Question] Prisma + Interfaces? #1359

Open
erandagan opened this issue Dec 14, 2024 · 1 comment
Open

[Question] Prisma + Interfaces? #1359

erandagan opened this issue Dec 14, 2024 · 1 comment

Comments

@erandagan
Copy link

erandagan commented Dec 14, 2024

Assume you have a domain that is modeled like so:

   +-------+
   | Media |
   +-------+
     /   \
    /     \
+-------+ +-------+
| Movie | | Show  |
+-------+ +-------+

Media is solely an API concept—Internally, Movie and Show are 2 separate tables, and there is no table that connects all of them to a single collection. So, with that in mind, we create the Media interface:

class Media { 
  title: string;
  poster: Poster; // defined elsewhere
  type: MediaType; // MOVIE | SHOW | ...
  ...
}

And then have both Movie and Show implement that interface:

builder.prismaObject("Movie", {
    interfaces: ["Media"], // Raises a TS error
    fields: (t) => ({
      title: t.exposeString("title", { nullable:false })
      type: t.field({
        type: MediaType,
        nullable: false,
        resolve: () => MediaType.MOVIE,
      }),
      poster: t.field({
        type: Poster,
        nullable: true,
        select: { poster: true, posterThumb: true },
        resolve: (movie) =>
          movie.poster && movie.posterThumb
            ? new MediaPoster(posters.url(movie.poster), movie.posterThumb)
            : null,
      }),
   })
  }
)

(Similar implementation on Show).

In this point, we already get a TS error saying the object shape must extend the interface - even though we have everything defined properly. If we ignore the error and run the code as-is, it still works, so let's try to move on for now.

We now have a query field on the type User called followedMedia, which queries the items the user follows. Let's define it in a naive way—

builder.prismaObject("User", {
    fields: (t) => ({
      followedMedia: t.field({
        type: ["Media"],
        nullable: false,
        select: {
          followedMovies: {
            select: {
              movie: true,
            },
          },
          followedShows: {
            select: {
              show: true,
            },
          },
        },
        resolve: async (user) => {
          return [
            ...user.followedMovies.map((f) => f.movie),
            ...user.followedShows.map((f) => f.show)),
          ];
        },
      })

But this raises a new error - the returned type, which is plain Prisma result objects, does not match the Media type.

Makes sense - we couldn't really expect this to work as-is, but the docs don't mention how we can tackle this use-case.

  1. We can't use prismaField, which handles the mapping of the returned type to the GQL type, since the GQL type is a custom interface.
  2. We can't wrap the returned items in a new Movie(f.movie) or new Show(f.show) since those types are internally generated by Pothos (so this only works for plain objects)
  3. We don't want to manually convert each item to be Media compatible, since we'll repeat the logic that already exists in the Movie type resolver itself (for instance, converting the poster and posterThumb fields into a single Poster object)

So I'm wondering, what are the options here?

@hayes
Copy link
Owner

hayes commented Dec 16, 2024

The general rule for interfaces is that object types need to by type-compatible with the type defined for an interface. Using a class for your interface is probably a bad starting place.

What you can do is use a simpler interface type that only contains the common properties of the 2 tables

interface Media {
  title: string
}

const Media = builder.interfaceRef<Media>('Media')
Media.implement({
  fields: (t) => ({
    title: t.exposeString('title'),
    poster: t.field({
        type: Poster,
        nullable: true,
        // since we aren't defining a resolver here, movie and show need to implement the poster field as well
    }),
  })
})

const Movie = builder.prismaObject("Movie", {
    interfaces: ["Media"], // Raises a TS error
    fields: (t) => ({
      poster: t.field({
        type: Poster,
        nullable: true,
        select: { poster: true, posterThumb: true },
        resolve: (movie) =>
          movie.poster && movie.posterThumb
            ? new MediaPoster(posters.url(movie.poster), movie.posterThumb)
            : null,
      }),
   })
  }
)

builder.prismaObject("User", {
    fields: (t) => ({
      followedMedia: t.field({
        type: [Media],
        nullable: false,
        select: {
          followedMovies: {
            select: {
              movie: true,
            },
          },
          followedShows: {
            select: {
              show: true,
            },
          },
        },
        resolve: async (user) => {
          return [
            // Adding brands will allow pothos to automatically determine the type during resolution
            ...Movie.addBrand(user.followedMovies),
            ...Show.addBrand(user.followedShows),
          ];
        },
      })

I've removed the type, because you can just query for __typename, but if you wanted to add the type, you are probably better off doing something similar to poster where its defined without a resolver on the interface (and not included in the type for the interface, and then you add the field definition with a resolver on each type that implements Media

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants