Converting a Rails has_many :through relationship?

Hello all!

I am trying to give my old Rails application a shiny new RedwoodJS frontend, but I’m running into a problem with modeling an existing has_many :through relationship, and since there may be some Rails expats here with experience, I figured i’d post, hoping someone has had a similar problem.

Basically I have a User model and a Role model, and they are joined through a relation table called “assignments” in the database, which I have mapped to be called “RolesOnUsers” in the prisma scheme. I am trying to model an explicit many-to-many (m-n) relationship according to the explicit variant in the prisma docs example here.

I expected to be able to query something like:

query FIND_USER_BY_ID($id: Int!) {
  user: user(id: $id) {
    firstName
    lastName
    roles {
      name
      id
    }
  }
}

and get the role names and id.

Instead I get the id of the relation table record, and since it doesn’t have a ‘name’ attribute it returns a ‘null’:

{
  "data": {
    "user": {
      "firstName": "Digitador",
      "lastName": "Dos",
      "roles": [
        {
          "name": null,
          "id": 361, // this is a relationship table id, not a Roles table id
          "__typename": "Role"
        }
      ],
      "__typename": "User"
    }
  },
  "loading": false,
  "networkStatus": 7,
  "stale": false
}

I’m still pretty new to Redwood and Prisma, so I’m still not clear where in my code I’m messing up. All examples I’ve seen for this type of relationship mapping Just Work™ :confused: .

Here are the relevant bits of my code (click triangle to expand):

schema.prisma
model User {
    @@map(name: "users")
    id    Int   @default(autoincrement()) @id

    // has_many :assignments
    roles.   RolesOnUsers[]    @relation(name: "toUser")
}


model Role {
    @@map(name: "roles")
    id                    Int         @default(autoincrement()) @id

    // has_many :assignments
    // has_many :users, :through => :assignments
    users                 RolesOnUsers[]      @relation(name: "toRole")

    name                  String?
}

model RolesOnUsers {
  @@map(name: "assignments")
   id          Int       @default(autoincrement()) @id

  // belongs_to :user
  user        User      @relation(name: "toUser", fields: [userId], references: [id])
  userId      Int       @map("user_id")

  // belongs_to :role
  role        Role      @relation(name: "toRole",fields: [roleId], references: [id])
  roleId      Int       @map("role_id")
}
users.js (service)
import { db } from 'src/lib/db'

export const users = () => {
  return db.user.findMany({ take: 50 })
}

export const user = ({ id }) => {
  return db.user.findOne({
    where: { id },
  })
}

export const User = {
  roles: (_obj, { root }) =>
    // db.user.findOne({ where: { id: root.id } }).roles(),
    db.user.findOne({ where: { id: root.id } }).roles(),
}
users.sdl.js
export const schema = gql`
  type User {
    id: Int!
    roles: [Role]
}

  type Query {
    users: [User!]!
    user(id: Int!): User
  }
`

And here are the GraphQL query and response:

GraphQL query on GraphiQL
query FIND_USER_BY_ID($id: Int!) {
  user: user(id: $id) {
    firstName
    lastName
    roles {
      name
      id
    }
  }
}
GraphQL response
{
  "data": {
    "user": {
      "firstName": "Digitador",
      "lastName": "Dos",
      "roles": [
        {
          "name": null,
          "id": 361,
          "__typename": "Role"
        }
      ],
      "__typename": "User"
    }
  },
  "loading": false,
  "networkStatus": 7,
  "stale": false
}

Unfortunately I can’t modify the database at all so I can’t use underscored implicit relationship tables as Prisma does, but I feel like this should be doable. Not sure if there’s anything special I need to be doing in the service side of things, or if it’s a schema/GraphQL issue.

I’m hoping you guys can give a recent Rails convert some guidance?

Thanks!

Simon

Ah, I think you might need to add an includes to your db.user.findOne in users. If you haven’t already, try:

export const user = ({ id }) => {
  return db.user.findOne({
    where: { id },
    includes: { roles: true },
  })
}

Hopefully this will do the trick, but I’m not familiar with explicit many-to-many relationships, so there might also be an issue I’m missing in schema.prisma.

Also, where are my manners, welcome!

Thank you @tctrautman (and thanks for the welcome) ! I’m assuming you meant ‘include:’ (‘includes:’ gives an error). Unfortunately it gives the exact same response as before, referencing the join table record.

Thanks for the suggestion though!

Simon

1 Like

More context: I enabled the query logging in the prisma client, and the GraphQL query is being resolved into the following four SQL queries. Note that it’s never actually querying the Roles table, just the user and assignments (“RolesByUser”) table twice:

Query 1

SELECT
	"public"."users"."id",
	"public"."users"."first_name",
	"public"."users"."last_name"
FROM
	"public"."users"
WHERE
	"public"."users"."id" = 24
LIMIT 10 OFFSET 0

Query 2

SELECT
	"public"."assignments"."id",
	"public"."assignments"."user_id",
	"public"."assignments"."role_id",
	"public"."assignments"."created_at",
	"public"."assignments"."updated_at",
	"public"."assignments"."deleted_at"
FROM
	"public"."assignments"
WHERE
	"public"."assignments"."user_id" IN(24)
	OFFSET 0

Query 3

SELECT
	"public"."users"."id"
FROM
	"public"."users"
WHERE
	"public"."users"."id" = 24
LIMIT 1

Query 4

SELECT
	"public"."assignments"."id",
	"public"."assignments"."user_id",
	"public"."assignments"."role_id",
	"public"."assignments"."created_at",
	"public"."assignments"."updated_at",
	"public"."assignments"."deleted_at"
FROM
	"public"."assignments"
WHERE
	"public"."assignments"."user_id" IN(24)
	OFFSET 0
  • Simon

Any ideas here @rob?

The “HMT” association is one of the very nice things about Rails and Active Record – and something many people moving to Prisma yearn for to be similar or simpler.

It’s been brought here here in this Community:

and in Prisma as well:

In short, I believe people are solving it – just like you noted above – via the “many-to-many” with Prisma’s implicit or explicit approach:

So a User and a Role and either an implicitly created _UserToRole or an explicitly UserRole join table or as they would name it RolesOnUsers.

The problem seen with the implicit table – and something I wish Prisma would improve on is that:

  • there are no timestamps on _UserToRole so you don’t know when a user was assigner a Role
  • You cannot store any other metadata or info on that join table

To do that would have to use the explicit method.

Ok - so back to your question :wink:

I expected to be able to query something like:

query FIND_USER_BY_ID($id: Int!) {
  user: user(id: $id) {
    firstName
    lastName
    roles {
      name
      id
    }
  }
}

In a project I am working on now, I have two models Tweet and Article where a there is a many to many relationship because a Tweet could have mentioned an article and so could many other tweets (where the article is defined by its url):

:point_right: This diagram shows the join table without the _ – this is the implicit _ArticleToTweet

image

When I am viewing the Tweet and I want to see the Article(s), my Prisma query is:

export const tweetById = ({ id }) => {
  return db.tweet.findOne({
    where: { id: id },
    include: {
      entry: true,
      articles: {
        include: {
          tags: { where: { confidence: { gte: 0.4 }, mentions: { gt: 0 } } },
        },
      },
      tweetContext: true,
      tweetCategories: true,
      tags: { where: { confidence: { gte: 0.4 }, mentions: { gt: 0 } } },
      tweetPriorities: true,
    },
  })
}

:rotating_light: Very Important! I also need:

export const Tweet = {
  articles: (_obj, { root }) =>
    db.tweet.findOne({ where: { id: root.id } }).articles(),
}

export const Article = {
  tweets: (_obj, { root }) =>
    db.article.findOne({ where: { id: root.id } }).tweets()
}

:spiral_notepad:And you might think this should be findMany and I did at first, too, but really this is saying “for each Article” there are many Tweets and vice versa when resolving in GraphQL (or that’s how I’ve understood it to work).

And in my cell to show the Tweet and the Article included to match the Prisma is:

export const QUERY = gql`
  query TweetQuery($id: String!) {
    tweet: tweetById(id: $id) {
      id
      createdAt
      updatedAt
      publishedAt
      author
      title
      content
      sentiment
      url

      articles {
        id
        title
        description
        url

        tags {
          label
          mentions
          confidence
          salience
          sentiment
        }
      }

      categories: tweetCategories {
        label
      }

      priorities: tweetPriorities {
        label
        terms: tweetPriorityTerms {
          label
        }
      }

      tags {
        label
        mentions
        confidence
        salience
        sentiment
        entityTypes
      }
    }
  }
`

And you can see that I can get both the Article on Tweet and Tweet on the Article:

Is it that same as a has_many through – maybe, maybe not.

So, far this is working, but it does take awhile to get comfortable.

That said, can see Prisma trying to work in some familiar Active Record -like features in their Nested Writes where they have a connectOrCreate which is similar to the find_or_create_by (or first_or_create_by).

As with many things, I hope Prisma will evolve to help make these things a bit easier. :crossed_fingers:

Thanks for reading.

4 Likes

BTW - want diagrams?

Try out:

and

More info at

3 Likes

In the example-blog I’m using an implicit has-many and adding that include key works to bring in the data from the other table.

Here’s the schema file I’m working with: https://github.com/redwoodjs/example-blog/blob/main/api/prisma/schema.prisma

In Rails lingo I’ve got the equivalent of a Post model and a Tag model with a has_many :tags, through: :post_to_tags on the Post

Here’s a console session showing the function call and the result:

rob$ DATABASE_URL=file:./dev.db node
Welcome to Node.js v14.4.0.
Type ".help" for more information.
> const { PrismaClient } = require('@prisma/client')
undefined
> const prisma = new PrismaClient()
undefined
> prisma.post.findMany({include: { tags: true }}).then(result => console.log(result))
Promise { <pending> }
> [
  {
    id: 1,
    title: 'Crucible Tool Lump Hammer',
    slug: 'crucible-tool-lump-hammer',
    author: 'Rob Cameron',
    body: 'The Crucible lump hammer has a 2.2 lb. hardened steel head and is ideal for assembly, mortising, setting holdfasts and dozens of other chores. Comfortable octagonal hickory handle with a waxed finish.',
    image: 'https://cdn.filestackcontent.com/uBtP96HHSsa6Kv6qjgMP',
    postedAt: 2019-08-10T00:00:00.000Z,
    tags: [ [Object], [Object], [Object] ]
  },
]

You can see at the end there that the tags are included. If I log those as well:

[
  { id: 1, name: 'wood' },
  { id: 2, name: 'steel' },
  { id: 4, name: 'woodworking' }
]

The syntax should be similar with implicit and explicit has manys, as long as you’ve got the relationships setup right in the schema file.

1 Like

Thanks for the reply Rob. I did look through the example blog, and it seems pretty straightforward when using implicit relationships. Unfortunately I can’t modify the db schema to adhere to prisma conventions and add an underscore to the join table, so I went with the explicit way. (Also some of my join tables have additional attributes I need to query at some point).

I expected that if I set the explicit relationship as directed, the query syntax would be the same as the implicit way but I can’t seem to traverse through to the roles table and keep getting the attributes of the join table. The query responds that the __typename is Role, but the attributes it returns are from the RolesOnUsers join table.

Perhaps this is a misunderstanding on my part and with explicit m-n relationships in prisma there’s more that I need to do to manually wire up or resolve the queries in Redwood

Thanks!

Welcome to the community @simonmd! I struggled with explicit many-to-many relations too, but finally got it working the way I wanted over here Subqueries in Prisma, Many-Many relation Do have a look if you haven’t already, see if that helps

Hmmmm Maybe this is just a copy/paste bug but I noticed there’s a period after the roles declaration in your schema:

model User {
    @@map(name: "users")
    id    Int   @default(autoincrement()) @id

    // has_many :assignments
    roles.   RolesOnUsers[]    @relation(name: "toUser")
}

But before you get to the GraphQL I’d try running a Prisma lookup directly to make sure it’s returning the data you think it is:

$ DATABASE_URL=file:./dev.db node

Welcome to Node.js v14.4.0.
Type ".help" for more information.

> const { PrismaClient } = require('@prisma/client')
undefined

> const db = new PrismaClient()
undefined

> db.user.findFirst().roles().then(roles => console.info(roles))

Does that return what you expect it would? If so then the next step is to look at the GraphQL SDL. If not then Prisma has some explaining to do!

Thanks Rob, I tried:

db.user.findFirst().roles().then(roles => console.info(roles))

and got:

Uncaught TypeError: db.user.findFirst is not a function

Not sure why that is, but it does seem to find findOne :pensive:

> db.user
{
  findOne: [Function (anonymous)],
  findMany: [Function (anonymous)],
  create: [Function (anonymous)],
  delete: [Function (anonymous)],
  update: [Function (anonymous)],
  deleteMany: [Function (anonymous)],
  updateMany: [Function (anonymous)],
  upsert: [Function (anonymous)],
  count: [Function (anonymous)],
  aggregate: [Function (anonymous)]
}

so I tried:

> db.user.findOne({where: { id: 30 }}).roles().then(roles => console.info(roles))

and got:

Promise { <pending> }
> (node:2599) UnhandledPromiseRejectionWarning: Error: 
Invalid `prisma.rolesOnUsers.findOne()` invocation in
REPL18:1:38


  error: Error validating datasource `DS`: The URL for datasource `DS` must start with the protocol `postgresql://`.
  -->  schema.prisma:8
   | 
 7 |   provider = "postgresql"
 8 |   url = env("DATABASE_URL")
   | 

Haven’t really invoked prisma directly through node so I’m probably doing something really noob… :pensive:

OK so I temporarily hardcoded the DATABASE_URL in the schema.prisma just to move forward, and tried again:

> db.user.findOne({where: { id: 30 }}).roles().then(roles => console.info(roles))
Promise { <pending> }
> [ { id: 51, userId: 30, roleId: 7 } ]

So prisma is definitely retrieving just the join table information, and nothing else…

I don’t have much context here @simonmd But Prisma findFirst is a new method in Prisma 2.7, which hasn’t been included in a Redwood release until yesterday’s v0.20.0. Unless you were using a Redwood Canary release, it would be expected that findFirst didn’t work.

Make sense?

Prisma findFirst is a new method in Prisma 2.7, which hasn’t been included in a Redwood release until yesterday’s v0.20.0.

Totally @thedavid , thanks!
I’ll hold up on upgrading this particular project so as to not introduce more variables to the problem until I figure out what to do with this relationship issue, which I believe is unrelated.

Simon

1 Like

Hmm that sounds like an issue you’ll want to open with Prisma. It does seem like it should be returning the actual roles data, not the join table’s data. It seems strange that you’d be the first person to experience this, though. So either there’s already an open Issue, or this is the expected behavior, somehow. :frowning: