AE
Ash Elixir•2y ago
Blibs

How to have a "virtual field" in a resource based from another resource?

I have a Organization resource that has a many to many relationship with my User resource. That relationship is defined by this field in the Organization resource:
many_to_many :users, User do
through UserOrganization

source_attribute_on_join_resource :organization_id
destination_attribute_on_join_resource :user_id
end
many_to_many :users, User do
through UserOrganization

source_attribute_on_join_resource :organization_id
destination_attribute_on_join_resource :user_id
end
The UserOrganization resource has a special attribute inside of which defined if the user is an admin inside that organization:
defmodule UserOrganization do
use Ash.Resource, data_layer: AshPostgres.DataLayer

attributes do
attribute :admin?, :boolean, allow_nil?: false, default: false
end

relationships do
...
end

postgres do
table "users_organizations"
...
end

...
end
defmodule UserOrganization do
use Ash.Resource, data_layer: AshPostgres.DataLayer

attributes do
attribute :admin?, :boolean, allow_nil?: false, default: false
end

relationships do
...
end

postgres do
table "users_organizations"
...
end

...
end
What I want to do is to create a virtual attribute in the Organization resource that will tell if that user (the actor) is an admin. My first approach was to add this new relationship to the resource:
has_many :users_organizations, UserOrganization
has_many :users_organizations, UserOrganization
So I can get access to the UserOrganization resource directly from Organization. After that, I tried creating a calculation that would join that relationship and "extract" the admin? field:
calculations do
calculate :admin?, :boolean, expr(users_organizations.admin?)
end
calculations do
calculate :admin?, :boolean, expr(users_organizations.admin?)
end
That don't work, and I'm not sure how exactly I would filter that calculation to only get the user_organization from the actor. My second approach was to try an aggregate:
aggregates do
first :admin?, :users_organizations, :admin? do

filter expr(user_id == ^actor(:id))
end
end
aggregates do
first :admin?, :users_organizations, :admin? do

filter expr(user_id == ^actor(:id))
end
end
This also doesn't work, and aggregates also will generate a more complex query instead of simply doing the join by organization_id and user_id. Any suggestion on how I can acchieve that?
16 Replies
ZachDaniel
ZachDaniel•2y ago
🤔 I can add an option for this we already have the concept of doing simple joins for first aggregates that go over only to_one relationships so all we really need to do is expose that as an option for cases where we can't tell
Blibs
BlibsOP•2y ago
That would be great! Right now my workaround is to have:
has_many :organizations, UserOrganization
has_many :organizations, UserOrganization
instead of
many_to_many :organizations, Organization do
through UserOrganization

source_attribute_on_join_resource :user_id
destination_attribute_on_join_resource :organization_id
end
many_to_many :organizations, Organization do
through UserOrganization

source_attribute_on_join_resource :user_id
destination_attribute_on_join_resource :organization_id
end
and do
preparations do
prepare build(load: [schools: [:school], organizations: [:organization]])
end
preparations do
prepare build(load: [schools: [:school], organizations: [:organization]])
end
instead of
preparations do
prepare build(load: [:schools, :organizations])
end
preparations do
prepare build(load: [:schools, :organizations])
end
Having that would allow me to make things simpler IMO
ZachDaniel
ZachDaniel•2y ago
oh, actually nevermind, I just realized you are looking for this to be scoped to the actor calculate :admin?, :boolean, expr(exists(user_organizations, user_id == ^actor(:id) and admin? == true)) thats what you want I think
Blibs
BlibsOP•2y ago
Not sure, if I try to load that calculation with:
Accounts.load!(org, [:admin?], actor: user)
Accounts.load!(org, [:admin?], actor: user)
I get:

** (ArgumentError) `nil` is not a Spark DSL module.

nil.spark_dsl_config()
(spark 1.1.15) lib/spark/dsl/extension.ex:134: Spark.Dsl.Extension.dsl!/1
(spark 1.1.15) lib/spark/dsl/extension.ex:163: Spark.Dsl.Extension.get_persisted/3
(ash 2.9.27) lib/ash/filter/filter.ex:3033: Ash.Filter.do_hydrate_refs/2
(ash 2.9.27) lib/ash/resource/calculation/expression.ex:131: Ash.Resource.Calculation.Expression.select/3
(ash 2.9.27) lib/ash/query/query.ex:1082: Ash.Query.select_and_load_calc/3
(ash 2.9.27) lib/ash/query/query.ex:1042: Ash.Query.load_resource_calculation/4
(elixir 1.14.4) lib/enum.ex:2468: Enum."-reduce/3-lists^foldl/2-0-"/3
(ash 2.9.27) lib/ash/api/api.ex:1708: Ash.Api.load/4
(ash 2.9.27) lib/ash/api/api.ex:1665: Ash.Api.load!/4
iex:8: (file)

** (ArgumentError) `nil` is not a Spark DSL module.

nil.spark_dsl_config()
(spark 1.1.15) lib/spark/dsl/extension.ex:134: Spark.Dsl.Extension.dsl!/1
(spark 1.1.15) lib/spark/dsl/extension.ex:163: Spark.Dsl.Extension.get_persisted/3
(ash 2.9.27) lib/ash/filter/filter.ex:3033: Ash.Filter.do_hydrate_refs/2
(ash 2.9.27) lib/ash/resource/calculation/expression.ex:131: Ash.Resource.Calculation.Expression.select/3
(ash 2.9.27) lib/ash/query/query.ex:1082: Ash.Query.select_and_load_calc/3
(ash 2.9.27) lib/ash/query/query.ex:1042: Ash.Query.load_resource_calculation/4
(elixir 1.14.4) lib/enum.ex:2468: Enum."-reduce/3-lists^foldl/2-0-"/3
(ash 2.9.27) lib/ash/api/api.ex:1708: Ash.Api.load/4
(ash 2.9.27) lib/ash/api/api.ex:1665: Ash.Api.load!/4
iex:8: (file)
ZachDaniel
ZachDaniel•2y ago
oh, sorry
Blibs
BlibsOP•2y ago
Either way, I think I would not be able to use it anyway since I want to load it when I get my user, meanign that I don't have an actor yet
ZachDaniel
ZachDaniel•2y ago
do you have a user_organizations relationship?
Blibs
BlibsOP•2y ago
Yep
ZachDaniel
ZachDaniel•2y ago
many_to_many :organizations, Organization do
through UserOrganization
join_relationship :user_organizations

source_attribute_on_join_resource :user_id
destination_attribute_on_join_resource :organization_id
end
many_to_many :organizations, Organization do
through UserOrganization
join_relationship :user_organizations

source_attribute_on_join_resource :user_id
destination_attribute_on_join_resource :organization_id
end
add that join_relationship
Blibs
BlibsOP•2y ago
defmodule Accounts.UserOrganization do
@moduledoc false

use Ash.Resource,
data_layer: AshPostgres.DataLayer

attributes do
attribute :admin?, :boolean, allow_nil?: false, default: false
end

relationships do
alias Accounts.{User, Organization}

belongs_to :user, User do
primary_key? true
allow_nil? false
attribute_writable? true
end

belongs_to :organization, Organization do
primary_key? true
allow_nil? false
attribute_writable? true
end
end

postgres do
table "users_organizations"

references do
reference :user, on_update: :update, on_delete: :delete
reference :organization, on_update: :update, on_delete: :delete
end

custom_indexes do
index ["\"admin?\""], name: "users_organizations_admin_index"
end

repo Repo
end
end
defmodule Accounts.UserOrganization do
@moduledoc false

use Ash.Resource,
data_layer: AshPostgres.DataLayer

attributes do
attribute :admin?, :boolean, allow_nil?: false, default: false
end

relationships do
alias Accounts.{User, Organization}

belongs_to :user, User do
primary_key? true
allow_nil? false
attribute_writable? true
end

belongs_to :organization, Organization do
primary_key? true
allow_nil? false
attribute_writable? true
end
end

postgres do
table "users_organizations"

references do
reference :user, on_update: :update, on_delete: :delete
reference :organization, on_update: :update, on_delete: :delete
end

custom_indexes do
index ["\"admin?\""], name: "users_organizations_admin_index"
end

repo Repo
end
end
ZachDaniel
ZachDaniel•2y ago
I was referring to a relationship you don't have in my example which was the cause of the error. Add the join_relationship option I mentioned above You don't have an actor yet, but you do have the user's id right? or not yet?
Blibs
BlibsOP•2y ago
Yep, I want to do that when ash_authenticate fetches the user So I actually have the user email/pwd haha The calculation worked after I added the join_relationship btw.
ZachDaniel
ZachDaniel•2y ago
Can you move this to be on the user instead?
aggregates do
list :admin_of, :organizations, :organization_id do
filter expr(admin? == true)
end
end
aggregates do
list :admin_of, :organizations, :organization_id do
filter expr(admin? == true)
end
end
You could put that on the user to see what organizations they are an admin of
Blibs
BlibsOP•2y ago
I could, but that means that when I wan´t to check for that information, I need to check in two places. Let's say I'm creating a page where I show a list of the user organizations, I would need to check, for each organization, if it is also inside the admin_of list in the user Btw, I think I found a but, not sure. If I remove the admin? == true from the calculation, it works. If I add that back, I get this error:
** (Ash.Error.Unknown) Unknown Error

* ** (ArgumentError) parameters must be of length 1 for query %Postgrex.Query{ref: #Reference<0.1342909402.405012485.110668>, name: "ecto_19973", statement: "SELECT o0.\"id\", o0.\"name\", o0.\"b2b?\", o0.\"inserted_at\", o0.\"updated_at\", exists((SELECT 1 AS \"result\" FROM \"public\".\"users_organizations\" AS su0 WHERE (su0.\"user_id\"::uuid = $1::uuid) AND (o0.\"id\" = su0.\"organization_id\")))::boolean FROM \"organizations\" AS o0", param_oids: [2950], param_formats: [:binary], param_types: [Postgrex.Extensions.UUID], columns: ["id", "name", "b2b?", "inserted_at", "updated_at", "exists"], result_oids: [2950, 1043, 16, 1114, 1114, 16], result_formats: [:binary, :binary, :binary, :binary, :binary, :binary], result_types: [Postgrex.Extensions.UUID, Postgrex.Extensions.Raw, Postgrex.Extensions.Bool, Postgrex.Extensions.Timestamp, Postgrex.Extensions.Timestamp, Postgrex.Extensions.Bool], types: {FeedbackCupcake.PostgresTypes, #Reference<0.1342909402.405143553.66672>}, cache: :reference}
(postgrex 0.17.1) lib/postgrex/query.ex:80: DBConnection.Query.Postgrex.Query.encode/3
(db_connection 2.5.0) lib/db_connection.ex:1336: DBConnection.maybe_encode/4
(db_connection 2.5.0) lib/db_connection.ex:707: DBConnection.execute/4
(ecto_sql 3.10.1) lib/ecto/adapters/postgres/connection.ex:102: Ecto.Adapters.Postgres.Connection.execute/4
(ecto_sql 3.10.1) lib/ecto/adapters/sql.ex:858: Ecto.Adapters.SQL.execute!/5
(ecto_sql 3.10.1) lib/ecto/adapters/sql.ex:828: Ecto.Adapters.SQL.execute/6
(ecto 3.10.2) lib/ecto/repo/queryable.ex:229: Ecto.Repo.Queryable.execute/4
(ecto 3.10.2) lib/ecto/repo/queryable.ex:19: Ecto.Repo.Queryable.all/3
** (Ash.Error.Unknown) Unknown Error

* ** (ArgumentError) parameters must be of length 1 for query %Postgrex.Query{ref: #Reference<0.1342909402.405012485.110668>, name: "ecto_19973", statement: "SELECT o0.\"id\", o0.\"name\", o0.\"b2b?\", o0.\"inserted_at\", o0.\"updated_at\", exists((SELECT 1 AS \"result\" FROM \"public\".\"users_organizations\" AS su0 WHERE (su0.\"user_id\"::uuid = $1::uuid) AND (o0.\"id\" = su0.\"organization_id\")))::boolean FROM \"organizations\" AS o0", param_oids: [2950], param_formats: [:binary], param_types: [Postgrex.Extensions.UUID], columns: ["id", "name", "b2b?", "inserted_at", "updated_at", "exists"], result_oids: [2950, 1043, 16, 1114, 1114, 16], result_formats: [:binary, :binary, :binary, :binary, :binary, :binary], result_types: [Postgrex.Extensions.UUID, Postgrex.Extensions.Raw, Postgrex.Extensions.Bool, Postgrex.Extensions.Timestamp, Postgrex.Extensions.Timestamp, Postgrex.Extensions.Bool], types: {FeedbackCupcake.PostgresTypes, #Reference<0.1342909402.405143553.66672>}, cache: :reference}
(postgrex 0.17.1) lib/postgrex/query.ex:80: DBConnection.Query.Postgrex.Query.encode/3
(db_connection 2.5.0) lib/db_connection.ex:1336: DBConnection.maybe_encode/4
(db_connection 2.5.0) lib/db_connection.ex:707: DBConnection.execute/4
(ecto_sql 3.10.1) lib/ecto/adapters/postgres/connection.ex:102: Ecto.Adapters.Postgres.Connection.execute/4
(ecto_sql 3.10.1) lib/ecto/adapters/sql.ex:858: Ecto.Adapters.SQL.execute!/5
(ecto_sql 3.10.1) lib/ecto/adapters/sql.ex:828: Ecto.Adapters.SQL.execute/6
(ecto 3.10.2) lib/ecto/repo/queryable.ex:229: Ecto.Repo.Queryable.execute/4
(ecto 3.10.2) lib/ecto/repo/queryable.ex:19: Ecto.Repo.Queryable.all/3
(ash_postgres 1.3.30) lib/data_layer.ex:628: AshPostgres.DataLayer.run_query/2
(ash 2.9.27) lib/ash/actions/read.ex:2618: Ash.Actions.Read.run_query/6
(ash 2.9.27) lib/ash/actions/read.ex:1339: anonymous fn/4 in Ash.Actions.Read.data_field/3
(ash 2.9.27) lib/ash/engine/engine.ex:537: anonymous fn/2 in Ash.Engine.run_iteration/1
(ash 2.9.27) lib/ash/engine/engine.ex:558: anonymous fn/4 in Ash.Engine.async/2
(elixir 1.14.4) lib/task/supervised.ex:89: Task.Supervised.invoke_mfa/2
(elixir 1.14.4) lib/task/supervised.ex:34: Task.Supervised.reply/4
(stdlib 4.3) proc_lib.erl:240: :proc_lib.init_p_do_apply/3
(ash 2.9.27) lib/ash/error/error.ex:461: Ash.Error.choose_error/2
(ash 2.9.27) lib/ash/error/error.ex:218: Ash.Error.to_error_class/2
(ash 2.9.27) lib/ash/actions/read.ex:184: Ash.Actions.Read.do_run/3
(ash 2.9.27) lib/ash/actions/read.ex:96: Ash.Actions.Read.run/3
(ash 2.9.27) lib/ash/api/api.ex:1802: Ash.Api.read!/3
iex:17: (file)
(ash_postgres 1.3.30) lib/data_layer.ex:628: AshPostgres.DataLayer.run_query/2
(ash 2.9.27) lib/ash/actions/read.ex:2618: Ash.Actions.Read.run_query/6
(ash 2.9.27) lib/ash/actions/read.ex:1339: anonymous fn/4 in Ash.Actions.Read.data_field/3
(ash 2.9.27) lib/ash/engine/engine.ex:537: anonymous fn/2 in Ash.Engine.run_iteration/1
(ash 2.9.27) lib/ash/engine/engine.ex:558: anonymous fn/4 in Ash.Engine.async/2
(elixir 1.14.4) lib/task/supervised.ex:89: Task.Supervised.invoke_mfa/2
(elixir 1.14.4) lib/task/supervised.ex:34: Task.Supervised.reply/4
(stdlib 4.3) proc_lib.erl:240: :proc_lib.init_p_do_apply/3
(ash 2.9.27) lib/ash/error/error.ex:461: Ash.Error.choose_error/2
(ash 2.9.27) lib/ash/error/error.ex:218: Ash.Error.to_error_class/2
(ash 2.9.27) lib/ash/actions/read.ex:184: Ash.Actions.Read.do_run/3
(ash 2.9.27) lib/ash/actions/read.ex:96: Ash.Actions.Read.run/3
(ash 2.9.27) lib/ash/api/api.ex:1802: Ash.Api.read!/3
iex:17: (file)
ZachDaniel
ZachDaniel•2y ago
uh....woah alright, well, I'll have to look at that tomorrrow Your best bet is probably to do one action to fetch the user w/o loading any data, and then use Api.load on that user to fetch all of the related things you want, with that user as the actor It would only really add a few ms to the request, and then you can use policies and stuff to enforce what that user can/can't access
Blibs
BlibsOP•2y ago
Got it, makes sense, I will try that and see how it goes

Did you find this page helpful?