Context Multitenancy - Invalid filter value or undefined table

I am trying to set up context multitenancy in a project. I created an organization resource, and this is the Tenant. Then, I created a post resource. Both modules are shown below. When I create an organization, it creates the DB with the prefix org_<id>. I can create a post with no issue using:

HgaSiteSuite.Blog.Post 
|> Ash.Changeset.for_create(:create, %{title: "tite", content: "content here", organization_id: "ae1ca97a-c13f-496a-802e-eb86edc6356b"}) 
|> Ash.Changeset.set_tenant("org_ae1ca97a-c13f-496a-802e-eb86edc6356b") 
|> Ash.create!()

When I try to fetch a post for a tenant though. I get undefined_table error with this:

 HgaSiteSuite.Blog.Post 
> Ash.Query.for_read(:feed) 
|> Ash.Query.set_tenant("ae1ca97a-c13f-496a-802e-eb86edc6356b") 
|> Ash.read!()

and I get an Invalid filter value when adding the prefix to the tenant

 HgaSiteSuite.Blog.Post 
> Ash.Query.for_read(:feed) 
|> Ash.Query.set_tenant("org_ae1ca97a-c13f-496a-802e-eb86edc6356b") 
|> Ash.read!()

Any help would be awesome; I am a noob with ash, so I’m sure I’m missing something obvious.

Organization Resource

defmodule HgaSiteSuite.Organizations.Organization do
  use Ash.Resource,
    domain: HgaSiteSuite.Organizations,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshAdmin.Resource]

  defimpl Ash.ToTenant do
    def to_tenant(%{id: id}, resource) do
      if Ash.Resource.Info.data_layer(resource) == AshPostgres.DataLayer &&
           Ash.Resource.Info.multitenancy_strategy(resource) == :context do
        "org_#{id}"
      else
        id
      end
    end
  end

  postgres do
    table("organizations")
    repo(HgaSiteSuite.Repo)

    manage_tenant do
      template(["org_", :id])
    end
  end

  actions do
    defaults([:read, :destroy])

    create :create do
      accept([:root_domain, :subdomains])
    end

    update :update do
      accept([:root_domain, :subdomains])
    end

    read :list do
      prepare(build(sort: [inserted_at: :desc]))
    end

    read :by_root_domain do
      argument(:root_domain, :string, allow_nil?: false)
      filter(expr(root_domain == ^arg(:root_domain)))
    end

    read :by_id do
      argument(:id, :uuid, allow_nil?: false)
      get?(true)
      filter(expr(id == ^arg(:id)))
    end
  end

  attributes do
    uuid_primary_key(:id)

    attribute :root_domain, :string do
      allow_nil?(false)
    end

    attribute(:subdomains, {:array, :string})

    attribute :inserted_at, :utc_datetime_usec do
      writable?(false)
      default(&DateTime.utc_now/0)
      match_other_defaults?(true)
      allow_nil?(false)
    end

    attribute :updated_at, :utc_datetime_usec do
      writable?(false)
      default(&DateTime.utc_now/0)
      update_default(&DateTime.utc_now/0)
      match_other_defaults?(true)
      allow_nil?(false)
    end
  end
end

Post Resource

defmodule HgaSiteSuite.Blog.Post do
  use Ash.Resource,
    domain: HgaSiteSuite.Blog,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshAdmin.Resource]

  admin do
    form do
      field :content, type: :markdown
    end
  end

  postgres do
    table("posts")
    repo(HgaSiteSuite.Repo)
  end

  multitenancy do
    strategy :context
    attribute :organization_id
  end

  relationships do
    belongs_to :organization, HgaSiteSuite.Organizations.Organization
  end

  actions do
    defaults([:read, :destroy])

    create :create do
      accept([
        :title,
        :content,
        :tags,
        :meta_title,
        :meta_description,
        :image_url,
        :organization_id
      ])

      change(fn changeset, _ctx ->
        title = Ash.Changeset.get_attribute(changeset, :title)
        slug = generate_slug(title)
        Ash.Changeset.change_attribute(changeset, :slug, slug)
      end)
    end

    update :update do
      accept([:title, :content, :tags, :slug, :meta_title, :meta_description, :image_url])
    end

    read :feed do
      prepare(build(sort: [inserted_at: :desc]))
    end

    read :by_slug do
      argument(:slug, :string, allow_nil?: false)
      filter(expr(slug == ^arg(:slug)))
    end

    read :by_id do
      argument(:id, :uuid, allow_nil?: false)
      get?(true)
      filter(expr(id == ^arg(:id)))
    end
  end

  attributes do
    uuid_primary_key(:id)

    attribute :organization_id, :uuid do
      allow_nil? false
    end

    attribute :title, :string do
      allow_nil?(false)
    end

    attribute(:meta_title, :string)

    attribute(:meta_description, :string)

    attribute(:image_url, :string)

    attribute(:tags, {:array, :string})

    attribute(:content, :string)

    attribute(:slug, :string)

    attribute :inserted_at, :utc_datetime_usec do
      writable?(false)
      default(&DateTime.utc_now/0)
      match_other_defaults?(true)
      allow_nil?(false)
    end

    attribute :updated_at, :utc_datetime_usec do
      writable?(false)
      default(&DateTime.utc_now/0)
      update_default(&DateTime.utc_now/0)
      match_other_defaults?(true)
      allow_nil?(false)
    end
  end

  defp generate_slug(title) when not is_nil(title) do
    title
    |> String.downcase()
    |> String.replace(~r/[^a-z0-9\s-]/, "")
    |> String.split()
    |> Enum.uniq()
    |> Enum.take(4)
    |> Enum.join("-")
  end

  defp generate_slug(_title), do: nil
end
2 Likes

So there are two ways to do what you’re looking to do:

Leverage the ToTenant protocol (recommended)

 HgaSiteSuite.Blog.Post 
> Ash.Query.for_read(:feed) 
|> Ash.Query.set_tenant(%Organization{id: "ae1ca97a-c13f-496a-802e-eb86edc6356b"}) 
|> Ash.read!()

By using the organization struct, the ToTenant protocol (that you’ve defined in your organization) will be used to determine the tenant for each resource, which provides the right value depending on context/attribute multitenancy

Use the parse_attribute option

https://hexdocs.pm/ash/dsl-ash-resource.html#multitenancy-parse_attribute

This option is rather old, so currently only an MFA (i.e not an anonymous function) is allowed.

In your organization, you’d do

multitenancy do
  ...
  parse_attribute {__MODULE__, :parse_tenant, []}
end

...

def parse_tenant("org_" <> id), do: id
def parse_tenant(id), do: id

Then, you would always use "org_<id>", which would provide the proper table name to context based multi tenant resources, and be parsed properly to just the id for the organization.

You could do both such that you can provide a string or an organization as well. :slight_smile:

1 Like

Hey Zach, thanks for the fast response!

I should have mentioned this in the original post as well. When I pass in the organization struct with the id, I get invalid filter value error as well.

HgaSiteSuite.Blog.Post 
|> Ash.Query.for_read(:feed) 
|> Ash.Query.set_tenant(%Organization{id: "ae1ca97a-c13f-496a-802e-eb86edc6356b"}) 
|> Ash.read!()

I think the set tenant protocol is being implemented properly (see above in my Organization module) because my invalid error value has the org_ prefix on my org id.

My error output:

** (Ash.Error.Invalid) Invalid Error

* Invalid filter value `"org_ae1ca97a-c13f-496a-802e-eb86edc6356b"` supplied in `#Ecto.Query<from p0 in HgaSiteSuite.Blog.Post, as: 0, where: type(as(0).organization_id, {:parameterized, Ash.Type.UUID.EctoType, []}) ==
  type(^"org_ae1ca97a-c13f-496a-802e-eb86edc6356b", {:parameterized, Ash.Type.UUID.EctoType, []}), order_by: [desc: as(0).inserted_at], select: struct(p0, [
  :id,
  :organization_id,
  :title,
  :meta_title,
  :meta_description,
  :image_url,
  :tags,
  :content,
  :slug,
  :inserted_at,
  :updated_at
])>`
  (elixir 1.17.0) lib/enum.ex:2531: Enum."-reduce/3-lists^foldl/2-0-"/3
  (elixir 1.17.0) lib/enum.ex:1829: Enum."-map_reduce/3-lists^mapfoldl/2-0-"/3
  (elixir 1.17.0) lib/enum.ex:2531: Enum."-reduce/3-lists^foldl/2-0-"/3
  (ecto 3.11.2) lib/ecto/repo/queryable.ex:214: Ecto.Repo.Queryable.execute/4
  (ecto 3.11.2) lib/ecto/repo/queryable.ex:19: Ecto.Repo.Queryable.all/3
  (ash_postgres 2.0.12) lib/data_layer.ex:758: anonymous fn/3 in AshPostgres.DataLayer.run_query/2
  (ash_postgres 2.0.12) lib/data_layer.ex:756: AshPostgres.DataLayer.run_query/2
  (ash 3.0.16) lib/ash/actions/read/read.ex:2423: Ash.Actions.Read.run_query/4
  (ash 3.0.16) lib/ash/actions/read/read.ex:447: anonymous fn/5 in Ash.Actions.Read.do_read/4
  (ash 3.0.16) lib/ash/actions/read/read.ex:777: Ash.Actions.Read.maybe_in_transaction/3
    (elixir 1.17.0) lib/process.ex:864: Process.info/2
    (ash 3.0.16) lib/ash/error/invalid.ex:3: Ash.Error.Invalid.exception/1
    (ash 3.0.16) /Users/jacobluetzow/Documents/Develop.nosync/hga/hga_site_suite/deps/splode/lib/splode.ex:211: Ash.Error.to_class/2
    (ash 3.0.16) lib/ash/error/error.ex:66: Ash.Error.to_error_class/2
    (ash 3.0.16) lib/ash/actions/read/read.ex:319: anonymous fn/2 in Ash.Actions.Read.do_run/3
    (ash 3.0.16) lib/ash/actions/read/read.ex:258: Ash.Actions.Read.do_run/3
    (ash 3.0.16) lib/ash/actions/read/read.ex:66: anonymous fn/3 in Ash.Actions.Read.run/3
    (ash 3.0.16) lib/ash/actions/read/read.ex:65: Ash.Actions.Read.run/3
    (ash 3.0.16) lib/ash.ex:1844: Ash.read/2
    (ash 3.0.16) lib/ash.ex:1803: Ash.read!/2
    iex:1: (file)

For :context based multi tenancy, you would’t have an attribute also configured. This is something we can make more ergonomic (read actions should be only trying to use the tenant as a filter if strategy == :attribute).

There is no need to have organization_id on HgaSiteSuite.Blog.Post if you are using context(schema) based multitenancy. If what you’re looking to do is be able to relate to the organization, what you’d do is something like this:

# in organization resource
# this sets up optional id-based multitenancy
multitenancy do
  strategy :attribute
  attribute :id
  global? true # this allows reads on the table *without* a tenant as well
end
# then if you want to relate to the organization, you'd do 
has_one :organization, Organization do
  no_attributes? true # the tenant will scope it to only one organization at any given time
end

Hope that helps :slight_smile:

1 Like

I’ve made a change here that will only use the tenant for filtering if the multitenancy strategy is :attribute.

2 Likes

Oh, that makes sense after you say it. Thank you. I removed the attribute option and we are gold. Thanks again!