Additional attributes on join table for many-to-many relationship

I have an app that has courses and teachers. The relationship is many to many since each course can have many teachers and each teacher can have many courses. In the join table/resource I have an additional attribute called role (one can be primary or extra teacher for a course). I managed to connect the resources so that I can create a new relationship by calling: TheSchoolApp.Courses.Teacher.update(teacher, %{courses: [%{id: course.id, role: "primary"}]}) The problem is that it doesn't fill in the role attribute. It just leaves it as nil.
The attributes and relationship for CourseTeacher is: attributes do uuid_primary_key(:id) attribute(:role, :string, allow_nil?: true) create_timestamp(:inserted_at) end relationships do belongs_to :teacher, TheSchoolApp.Courses.Teacher, primary_key?: true, allow_nil?: false belongs_to :course, TheSchoolApp.Courses.Course, primary_key?: true, allow_nil?: false end And the action for teacher is: update :update do argument :courses, {:array, :map} do allow_nil? false end change manage_relationship(:courses, type: :append_and_remove) end
14 Replies
barnabasj
barnabasj3y ago
I think, but am unsure, that the attributes you pass in here are only looked at as courses. What you could do to debug this is look at the config for :append_and_remove which is the same as:
[
on_lookup: :relate,
on_no_match: :error,
on_match: :ignore,
on_missing: :unrelate
]
[
on_lookup: :relate,
on_no_match: :error,
on_match: :ignore,
on_missing: :unrelate
]
I guess In your case the on_lookup action is called. Which would be the default create action on the join resource. You could declare your own primary action in the resource and check what kind of values you get in the changeset, maybe you can then extrapolate from there. Otherwise, you could just create an action on the join resource that takes the role and the course/teacher id and join them that way.
No description
barnabasj
barnabasj3y ago
Ash HQ
Module: Ash.Changeset
View the documentation for Ash.Changeset on Ash HQ.
victorbjorklund
victorbjorklundOP3y ago
It does seems like ash removes the role attribute: if I override with a manual create action:

def create(changeset, _, _) do
IO.inspect("--------------------------------")
IO.inspect(changeset)
IO.inspect("--------------------------------")
changeset
end

def create(changeset, _, _) do
IO.inspect("--------------------------------")
IO.inspect(changeset)
IO.inspect("--------------------------------")
changeset
end
TheSchoolApp.Courses.Teacher.update(teacher, %{courses: [%{id: course.id, role: "primary"}]})
[debug] QUERY OK db=2.2ms idle=1559.9ms
begin []
↳ anonymous fn/3 in Ash.Changeset.with_hooks/3, at: lib/ash/changeset/changeset.ex:1585
"--------------------------------"
#Ash.Changeset<
api: TheSchoolApp.Courses,
action_type: :create,
action: :create,
attributes: %{
course_id: "ceefa99d-efcc-42b4-9fde-b8732df8d584",
id: "69b86e15-90d7-487c-b07b-e679d529f748",
inserted_at: ~U[2023-03-31 11:04:21.988606Z],
teacher_id: "58521b60-4af7-4e8b-b76b-721dc9e00f27"
},
relationships: %{},
errors: [],
data: #TheSchoolApp.Courses.CourseTeacher<
course: #Ash.NotLoaded<:relationship>,
teacher: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:built, "course_teacher">,
id: nil,
role: nil,
inserted_at: nil,
teacher_id: nil,
course_id: nil,
aggregates: %{},
calculations: %{},
__order__: nil,
...
>,
context: %{actor: nil, authorize?: false},
valid?: true
>
"--------------------------------"
[debug] QUERY OK db=5.3ms
INSERT INTO "courses" ("id") VALUES ($1) ["ceefa99d-efcc-42b4-9fde-b8732df8d584"]
↳ AshPostgres.DataLayer.create/2, at: lib/data_layer.ex:1037
[debug] QUERY OK db=0.8ms
TheSchoolApp.Courses.Teacher.update(teacher, %{courses: [%{id: course.id, role: "primary"}]})
[debug] QUERY OK db=2.2ms idle=1559.9ms
begin []
↳ anonymous fn/3 in Ash.Changeset.with_hooks/3, at: lib/ash/changeset/changeset.ex:1585
"--------------------------------"
#Ash.Changeset<
api: TheSchoolApp.Courses,
action_type: :create,
action: :create,
attributes: %{
course_id: "ceefa99d-efcc-42b4-9fde-b8732df8d584",
id: "69b86e15-90d7-487c-b07b-e679d529f748",
inserted_at: ~U[2023-03-31 11:04:21.988606Z],
teacher_id: "58521b60-4af7-4e8b-b76b-721dc9e00f27"
},
relationships: %{},
errors: [],
data: #TheSchoolApp.Courses.CourseTeacher<
course: #Ash.NotLoaded<:relationship>,
teacher: #Ash.NotLoaded<:relationship>,
__meta__: #Ecto.Schema.Metadata<:built, "course_teacher">,
id: nil,
role: nil,
inserted_at: nil,
teacher_id: nil,
course_id: nil,
aggregates: %{},
calculations: %{},
__order__: nil,
...
>,
context: %{actor: nil, authorize?: false},
valid?: true
>
"--------------------------------"
[debug] QUERY OK db=5.3ms
INSERT INTO "courses" ("id") VALUES ($1) ["ceefa99d-efcc-42b4-9fde-b8732df8d584"]
↳ AshPostgres.DataLayer.create/2, at: lib/data_layer.ex:1037
[debug] QUERY OK db=0.8ms
\ ឵឵឵
\ ឵឵឵3y ago
There was some discussion in the last days about supporting additional attributes on many_to_many join resources, but at the moment I believe that it is not supported natively. If a teacher will only ever be part of a course with one role, then you can add:
identities do
identity :teacher_course, [:teacher_id, :course_id]
end
identities do
identity :teacher_course, [:teacher_id, :course_id]
end
Then select directly from the join resource to find the teacher's role.
victorbjorklund
victorbjorklundOP3y ago
Aha I see. Im trying to find that disc, do you know where it was? I guess I can do it manually like normal ecto if I know that it will be added in the future (because still a cost of using ash so wanna know that I actually save some time vs just using normal ecto etc).
\ ឵឵឵
\ ឵឵឵3y ago
Probably worth opening a new one anyways here or in #ideas since it's a useful topic. The main problem is that the Ash concept of many_to_many resources intends to be as transparent as belongs_to and has_one, so it's primarily about deciding how to attach that additional information, or whether to support it at all.
ZachDaniel
ZachDaniel3y ago
You can update the join attributes, you just have to use this rather explicit/verbose format
manage_relationship(...., on_create: {:create, :action_name, :join_table_action_name, [:list, :of, :join_table, :params]}
manage_relationship(...., on_create: {:create, :action_name, :join_table_action_name, [:list, :of, :join_table, :params]}
So you'd have to specify every option
victorbjorklund
victorbjorklundOP3y ago
Thanks Zach! Hmm im probably missing something when i put this in the :update action in the teacher resource:

change manage_relationship(:courses, on_create: {:create, :create, :course_teacher, [:teacher_id, :course_id, :role]})

change manage_relationship(:courses, on_create: {:create, :create, :course_teacher, [:teacher_id, :course_id, :role]})
Then I get this complication errror:
** (EXIT from #PID<0.6305.0>) shell process exited with reason: an exception was raised:
** (Spark.Error.DslError) [TheSchoolApp.Courses.Teacher]
actions -> update -> update -> change -> manage_relationship -> courses:
The following error was raised when validating options provided to manage_relationship.

** (NimbleOptions.ValidationError) unknown options [:on_create], valid options are: [:type, :authorize?, :eager_validate_with, :on_no_match, :value_is_key, :identity_priority, :use_identities, :on_lookup, :on_match, :on_missing, :error_path, :meta, :ignore?]
....
** (EXIT from #PID<0.6305.0>) shell process exited with reason: an exception was raised:
** (Spark.Error.DslError) [TheSchoolApp.Courses.Teacher]
actions -> update -> update -> change -> manage_relationship -> courses:
The following error was raised when validating options provided to manage_relationship.

** (NimbleOptions.ValidationError) unknown options [:on_create], valid options are: [:type, :authorize?, :eager_validate_with, :on_no_match, :value_is_key, :identity_priority, :use_identities, :on_lookup, :on_match, :on_missing, :error_path, :meta, :ignore?]
....
I guess im putting the on_create in the wrong place somehow
ZachDaniel
ZachDaniel3y ago
ah, sorry I just misspoke on_no_match That is just one of the options you'll need to specify to make this work though Here is what type: :append_and_remove is actually doing under the hood
on_lookup: :relate,
on_no_match: :error,
on_match: :ignore,
on_missing: :unrelate
on_lookup: :relate,
on_no_match: :error,
on_match: :ignore,
on_missing: :unrelate
victorbjorklund
victorbjorklundOP3y ago
aha okay i see. I will try it!
ZachDaniel
ZachDaniel3y ago
So to have append_and_remove behavior while setting a join table attribute, you probably want this.
on_lookup: {:relate_and_update, :join_table_create_action, :lookup_action, [:role]},
on_no_match: :error,
on_match: :ignore,
on_missing: :unrelate
on_lookup: {:relate_and_update, :join_table_create_action, :lookup_action, [:role]},
on_no_match: :error,
on_match: :ignore,
on_missing: :unrelate
Where :join_table_create_action is a create action on the joining resource and :lookup_action is the read action on the destination resource Actually, you don't have to specify the others since they don't change. So it could also be:
type: :append_and_remove,
on_lookup: {:relate_and_update, :join_table_create_action, :lookup_action, [:role]}
type: :append_and_remove,
on_lookup: {:relate_and_update, :join_table_create_action, :lookup_action, [:role]}
victorbjorklund
victorbjorklundOP3y ago
Thank you! It worked! Sorry for all the questions.
ZachDaniel
ZachDaniel3y ago
Not a problem 😄 Glad we got it sorted 🥳
victorbjorklund
victorbjorklundOP3y ago
Hopefully I can "pay it back" in the future by creating some content around ash 🙂 Got to learn myself first

Did you find this page helpful?