improvement: support specifying that some options are modules

This commit is contained in:
Zach Daniel 2020-10-29 00:14:01 -04:00
parent a85315597f
commit 46efda4db4
No known key found for this signature in database
GPG key ID: C377365383138D4B
6 changed files with 133 additions and 4 deletions

View file

@ -0,0 +1,74 @@
# Multitenancy
Multitenancy is the idea of splitting up your data into discrete areas, typically by customer. One of the most common examples of this, is the idea of splitting up a postgres database into "schemas" one for each customer that you have. Then, when making any queries, you ensure to always specify the "schema" you are querying, and you never need to worry about data crossing over between customers. The biggest benefits of this kind of strategy are the simplification of authorization logic, and better performance. Instead of all queries from all customers needing to use the same large table, they are each instead all using their own smaller tables. Another benefit is that it is much easier to delete a single customer's data on request.
In Ash, there are a two primary strategies for implementing multitenancy. The first (and simplest) works for any data layer that supports filtering, and requires very little maintenance/mental overhead. It is done via expecting a given attribute to line up with the `tenant`, and is called `:attribute`. The second, is based on the data layer backing your resource, and is called `:context`. For information on
context based multitenancy, see the documentation of your datalayer. For example, `AshPostgres` uses postgres schemas. While the `:attribute` strategy is simple to implement, it also offers fewer advantages, primarily acting as another way to ensure your data is filtered to the correct tenant.
## Attribute Multitenancy
```elixir
defmodule MyApp.Users do
use Ash.Resource, ...
multitenancy do
strategy :attribute
attribute :organization_id
end
...
relationships do
belongs_to :organization, MyApp.Organization
end
end
```
In this case, if you were to try to run a query without specifying a tenant, you would get an error telling you that the tenant is required.
Setting the tenant when using the code API is done via `Ash.Query.set_tenant/2` and `Ash.Changeset.set_tenant/2`. If you are using an extension, such as `AshJsonApi` or `AshGraphql` the method of setting tenant context is explained in that extension's documentation.
Example usage of the above:
```elixir
# Error when not setting a tenant
MyApp.Users
|> Ash.Query.filter(name == "fred")
|> MyApi.read!()
** (Ash.Error.Unknown)
* "Queries against the Helpdesk.Accounts.User resource require a tenant to be specified"
(ash 1.22.0) lib/ash/api/api.ex:944: Ash.Api.unwrap_or_raise!/2
# Automatically filtering by `organization_id == 1`
MyApp.Users
|> Ash.Query.filter(name == "fred")
|> Ash.Query.set_tenant(1)
|> MyApi.read!()
[...]
# Automatically setting `organization_id` to `1`
MyApp.Users
|> Ash.Changeset.new(name: "fred")
|> Ash.Changeset.set_tenant(1)
|> MyApi.create!()
%MyApp.User{organization_id: 1}
```
If you want to enable running queries _without_ a tenant as well as queries with a tenant, the `global?` option supports this. You will likely need to incorporate this ability into any authorization rules though, to ensure that users from one tenant can't access other tenant's data.
```elixir
multitenancy do
strategy :attribute
attribute :organization_id
global? true
end
```
You can also provide the `parse_attribute?` option if the tenant being set doesn't exactly match the attribute value, e.g the tenant is `org_10` and the attribute is `organization_id`, which requires just `10`.
## Context Multitenancy
For `AshPostgres` multitenancy, see the [guide](https://hexdocs.pm/ash_postgres/multitenancy.html)

View file

@ -40,6 +40,7 @@ defmodule Ash.Dsl.Entity do
entities: [],
describe: "",
args: [],
modules: [],
schema: [],
auto_set_fields: []
]

View file

@ -551,6 +551,13 @@ defmodule Ash.Dsl.Extension do
extension = unquote(extension)
section = unquote(Macro.escape(section))
value =
if field in section.modules do
Ash.Dsl.Extension.expand_alias(value, __CALLER__)
else
value
end
quote do
current_sections = Process.get({__MODULE__, :ash_sections}, [])
@ -612,7 +619,12 @@ defmodule Ash.Dsl.Extension do
end)
end)
Ash.Dsl.Extension.build_entity_options(options_mod_name, entity.schema, nested_entity_path)
Ash.Dsl.Extension.build_entity_options(
options_mod_name,
entity.schema,
entity.modules,
nested_entity_path
)
args = Enum.map(entity.args, &Macro.var(&1, mod_name))
@ -640,7 +652,25 @@ defmodule Ash.Dsl.Extension do
nested_entity_mods = unquote(Macro.escape(nested_entity_mods))
nested_entity_path = unquote(Macro.escape(nested_entity_path))
arg_values = unquote(args)
arg_values =
entity_args
|> Enum.zip(unquote(args))
|> Enum.map(fn {key, value} ->
if key in entity.modules do
Ash.Dsl.Extension.expand_alias(value, __CALLER__)
else
value
end
end)
opts =
Enum.map(opts, fn {key, value} ->
if key in entity.modules do
{Ash.Dsl.Extension.expand_alias(key, __CALLER__), value}
else
{key, value}
end
end)
quote do
# This `try do` block scopes the imports/unimports properly
@ -771,16 +801,28 @@ defmodule Ash.Dsl.Extension do
end
@doc false
def build_entity_options(module_name, schema, nested_entity_path) do
def build_entity_options(module_name, schema, modules, nested_entity_path) do
Module.create(
module_name,
quote bind_quoted: [schema: Macro.escape(schema), nested_entity_path: nested_entity_path] do
quote bind_quoted: [
schema: Macro.escape(schema),
nested_entity_path: nested_entity_path,
modules: modules
] do
@moduledoc false
for {key, _value} <- schema do
defmacro unquote(key)(value) do
key = unquote(key)
nested_entity_path = unquote(nested_entity_path)
modules = unquote(modules)
value =
if key in modules do
Ash.Dsl.Extension.expand_alias(value, __CALLER__)
else
value
end
quote do
current_opts = Process.get({:builder_opts, unquote(nested_entity_path)}, [])
@ -798,4 +840,10 @@ defmodule Ash.Dsl.Extension do
module_name
end
def expand_alias({:__aliases__, _, _} = ast, env),
do: Macro.expand(ast, %{env | lexical_tracker: nil})
def expand_alias(ast, _env),
do: ast
end

View file

@ -25,6 +25,7 @@ defmodule Ash.Dsl.Section do
imports: [],
schema: [],
describe: "",
modules: [],
entities: [],
sections: []
]

View file

@ -61,6 +61,7 @@ defmodule Ash.Notifier.PubSub do
@publish,
@publish_all
],
modules: [:module],
schema: [
module: [
type: :atom,

View file

@ -85,6 +85,7 @@ defmodule Ash.Resource.Dsl do
destination_field: :word_text
"""
],
modules: [:destination],
target: Ash.Resource.Relationships.HasOne,
schema: Ash.Resource.Relationships.HasOne.opt_schema(),
args: [:name, :destination]
@ -104,6 +105,7 @@ defmodule Ash.Resource.Dsl do
"""
],
target: Ash.Resource.Relationships.HasMany,
modules: [:destination],
schema: Ash.Resource.Relationships.HasMany.opt_schema(),
args: [:name, :destination]
}
@ -126,6 +128,7 @@ defmodule Ash.Resource.Dsl do
destination_field_on_join_table: :book_id
"""
],
modules: [:destination, :through],
target: Ash.Resource.Relationships.ManyToMany,
schema: Ash.Resource.Relationships.ManyToMany.opt_schema(),
transform: {Ash.Resource.Relationships.ManyToMany, :transform, []},
@ -147,6 +150,7 @@ defmodule Ash.Resource.Dsl do
destination_field: :word_text
"""
],
modules: [:destination],
target: Ash.Resource.Relationships.BelongsTo,
schema: Ash.Resource.Relationships.BelongsTo.opt_schema(),
args: [:name, :destination]