Absinthe - Many to many assoc with index table

Hello.

I’m using the excellent Absinthe package, I’m trying to create a many to many association with an Index table, it looks like this:

defmodule Vulkan.Course do
  use Vulkan.Web, :model

  schema "courses" do
    field :name, :string
    field :description, :string
    
    many_to_many :users, Vulkan.User, join_through: "user_courses"

    timestamps()
  end
  .
  .
  .
end
defmodule Vulkan.User do
  use Vulkan.Web, :model

  schema "users" do
    field :name, :string
    field :handle, :string
    field :email, :string
    field :is_admin, :boolean, default: false
    field :encrypted_password, :string
    
    field :password, :string, virtual: true
    
    many_to_many :courses, Vulkan.Course, join_through: "user_courses"

    timestamps()
  end
  .
  .
  .
end

And the index looks like this:

defmodule Vulkan.UserCourse do
  use Vulkan.Web, :model

  schema "user_courses" do
    belongs_to :user, Vulkan.User
    belongs_to :course, Vulkan.Course

    timestamps()
  end
end

Just in case, the migration:

defmodule Vulkan.Repo.Migrations.CreateUserCourse do
  use Ecto.Migration

  def change do
    create table(:user_courses) do
      add :user_id, references(:users, on_delete: :nothing), null: false
      add :course_id, references(:courses, on_delete: :nothing), null: false

      timestamps()
    end
    create index(:user_courses, [:user_id])
    create index(:user_courses, [:course_id])
    create index(:user_courses, [:user_id, :course_id], unique: true)
  end
end

Now my question is, how would I resolve the association? Should I create a type and resolve the association through it?

object :user_course do
    field :id, :id
    field :user_id, :id
    field :course_id, :id
end

object :user do
    field :id, :id
    field :name, :string
    field :handle, :string
    field :email, :string
    field :is_admin, :boolean
    field :posts, list_of(:post), resolve: assoc(:posts)
    field :courses, list_of(:course), resolve: assoc(:user_courses)
end

This approach doesn’t seem to work, because I’ve inserted a UserCourse with user_id; 1 and course_id: 1 (both exist in the database) and when I run the query

query posts {
  users {
    id
    name
    courses {
      id
      name
    }
  }
}

I get the response

{
  "data": {
    "users": [
      {
        "name": "Andrés Pérez",
        "id": "1",
        "courses": null
      },
      {
        "name": "Jon Doe",
        "id": "2",
        "courses": null
      }
    ]
  }
}

I can’t manage to find the docs for Absinthe-Ecto, so I’m quite lost.

How should I be doing it?

Any help is highly appreciated, thank you so much for your time.

2 Likes

What is your users query schema and courses resolver?

I have an schema nearly identical to yours, and I ended up writing the resolver “long hand.” There’s probably a better way to do this (and I’d be interested in hearing it)…

  query = from u in User,
            join: uc in Vulkan.UserCourse, where: u.user_id == uc.id,
            join: c in Vulkan.Course, where: uc.course_id == c.id,
           #optional to query a user:  where: u.id == ^user_id,
            select: %{
              ... fields...
            }
     Repo.one(query)

You could clean the joins up. I’m just comfortable with SQL and Ecto is my first relational mapper…

join: uc in assoc(u, :user_courses)

1 Like

You have assoc(:user_courses) but the association name on your model is assoc(:courses). assoc/1 just piggy backs off of the Ecto association logic.

1 Like

I’m not sure how this ends up fitting in. Where are you putting this, inside the field :courses resolver? The problem with this is that if you do

{
  users {
    courses { name }
  }
}

You’ve now got an N + 1 pattern. If you use the assoc helper getting each user’s courses are batched together so you do just 1 database query regardless of how many users there are.

2 Likes

I’ve already tried field :courses, list_of(:course), resolve: assoc(:courses) but it just throws an error:

** (CaseClauseError) no case clause matching: %Ecto.Association.ManyToMany{cardinality: :many, defaults: , field: :courses, join_keys: [user_id: :id, course_id: :id], join_through: “user_courses”, on_cast: nil, on_delete: :nothing, on_replace: :raise, owner: Vulkan.User, owner_key: :id, queryable: Vulkan.Course, related: Vulkan.Course, relationship: :child, unique: false}
lib/absinthe/ecto.ex:118: Absinthe.Ecto.ecto_batch/4

And the rows exist in the database

iex(2)> Repo.all(UserCourse)
[debug] QUERY OK source="user_courses" db=4.6ms
SELECT u0."id", u0."user_id", u0."course_id", u0."inserted_at", u0."updated_at" FROM "user_courses" AS u0 []
[%Vulkan.UserCourse{__meta__: #Ecto.Schema.Metadata<:loaded, "user_courses">,
  course: #Ecto.Association.NotLoaded<association :course is not loaded>,
  course_id: 1, id: 1, inserted_at: ~N[2017-01-26 15:08:58.934751],
  updated_at: ~N[2017-01-26 15:08:58.948511],
  user: #Ecto.Association.NotLoaded<association :user is not loaded>,
  user_id: 1}]
iex(3)> Repo.get(User, 1)
[debug] QUERY OK source="users" db=1.5ms
SELECT u0."id", u0."name", u0."handle", u0."email", u0."is_admin", u0."encrypted_password", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = $1) [1]
%Vulkan.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
 courses: #Ecto.Association.NotLoaded<association :courses is not loaded>,
 email: "im.oxyrus@gmail.com",
 encrypted_password: "$2b$04$Dp6Ut/ZNeepPtWoa6Hsc9OIo.p2SgycHTy0Jb2A1Jz31aeNoG1gsS",
 handle: "Oxyrus", id: 1, inserted_at: ~N[2017-01-25 22:07:03.575043],
 is_admin: true, name: "Andrés Pérez", password: nil,
 post_comments: #Ecto.Association.NotLoaded<association :post_comments is not loaded>,
 posts: #Ecto.Association.NotLoaded<association :posts is not loaded>,
 updated_at: ~N[2017-01-25 22:07:03.584294]}
iex(4)> Repo.get(Course, 1)
[debug] QUERY OK source="courses" db=63.4ms
SELECT c0."id", c0."name", c0."description", c0."enrolled_students", c0."inserted_at", c0."updated_at" FROM "courses" AS c0 WHERE (c0."id" = $1) [1]
%Vulkan.Course{__meta__: #Ecto.Schema.Metadata<:loaded, "courses">,
 description: "Atque eius magni in corporis commodi. Quas ducimus eos sunt iure ad blanditiis voluptatem ab!",
 enrolled_students: 3196, id: 1, inserted_at: ~N[2017-01-25 22:07:03.748529],
 name: "Nostrum itaque qui eos provident.",
 updated_at: ~N[2017-01-25 22:07:03.748534],
 users: #Ecto.Association.NotLoaded<association :users is not loaded>}
1 Like

You’ll want to update Absinthe.Ecto, support for many to many was only merged last night.

2 Likes

Working, thank you and @bruce so much for making Absinthe!

For future reference:

field :courses, list_of(:course), resolve: assoc(:courses)

def all(_args, _info) do
    users = Repo.all(User) |> Repo.preload(:courses)
    {:ok, users}
end
2 Likes

You don’t need to preload the courses. If the query asks for them they’ll get loaded by assoc and if the query does not, it won’t. Right now you’re loading them whether the query asks for it or not.

EDIT:
You can make your resolver as simple as

def all(_args, _info) do
  {:ok, Repo.all(User)}
end

Yay batching!

2 Likes

I just removed the preload ( {:ok, Repo.all(User)} ) and I get the error:

[error] #PID<0.423.0> running Vulkan.Endpoint terminated
Server: elixir-oxyrus.c9users.io:8081 (http)
Request: POST /api
** (exit) an exception was raised:
** (CaseClauseError) no case clause matching: %Ecto.Association.ManyToMany{cardinality: :many, defaults: , field: :courses, join_keys: [user_id: :id, course_id: :id], join_through: “user_courses”, on_cast: nil, on_delete: :nothing, on_replace: :raise, owner: Vulkan.User, owner_key: :id, queryable: Vulkan.Course, related: Vulkan.Course, relationship: :child, unique: false}

1 Like

Can you provide the full strack trace? You ran mix deps.update absinthe_ecto ?

1 Like

Yes, I deleted my builds and compiled again and it’s working, thank you so much for your time @benwilson512!

2 Likes

woot! glad it’s working :slight_smile:

4 Likes

I have the same configuration, except I’m wondering how to resolve UserCourse timestamps?

I guess the query could look like this:

{
    users {
       id
       name
       courses {
           id
           name
           inserted_at
       }
    }
}

Running this query yields:

GraphQL error: Cannot query field "inserted_at" on type "Course".

I need more information in order to help. I need the ecto schemas for users and courses to figure out what they’re returning, and I need the :course graphql object. Basically, I need all the code that is shown here.

The error is definitely a bit confusing because it doesn’t look like inserted at is on the user type, but there’s not enough here for me to know what’s going on.

The ecto schemas for users and courses are in the OP from @Oxyrus. (I checked close and I have the same configuration / setup as him, only difference for me is course is called place…)

The :course graphql object is:

object :course do
    field :id, :id
    field :name, :string
    field :users, list_of(:user), resolve: assoc(:users)
end

I mean the issue is the details matter. I don’t know what user_courses is. Is it a join relation, is it the courses themselves. Based on what you have shown me everything works fine. That means that if you’re having an issue it has to do with code you haven’t shown me yet.

(post withdrawn by author, will be automatically deleted in 24 hours unless flagged)

It looks like if your code is the same as OP’s, you’re missing the timestamps in the Absinthe schema.

object :user_course do
    field :id, :id
    field :user_id, :id
    field :course_id, :id

    #add this
    field :inserted_at, :timestamptz #custom scalar for timestamps, you'd need to define elsewhere
end

object :user do
    field :id, :id
    field :name, :string
    field :handle, :string
    field :email, :string
    field :is_admin, :boolean
    field :posts, list_of(:post), resolve: assoc(:posts)
    field :courses, list_of(:course), resolve: assoc(:user_courses)

end
1 Like

Thanks for the suggestion but I get the same error. Check out the repo https://github.com/romseguy/phoenix_app if you want :slight_smile:

Same issue, same fix. You’re getting a different error now—the first error you posted was on the UserCourse object, now you’re getting it on the user object (going by what’s in the readme on your project.)