Current section

Files

Jump to
triplex lib triplex.ex
Raw

lib/triplex.ex

defmodule Triplex do
@moduledoc """
This is the main module of Triplex.
The main objetive of it is to make a little bit easier to manage tenants
through postgres db schemas or equivalents, executing queries and commands
inside and outside the tenant without much boilerplate code.
## Using the tenant
This module has useful functions to manage your tenants, like `create/1`,
`rename/2` and `drop/1`, but if you're trying to apply the tenant to a
query, changeset or schema, stay with your application `Repo`, sending the
prefix. Like this:
Repo.all(User, prefix: Triplex.to_prefix("my_tenant"))
It's a good idea to call `Triplex.to_prefix` on your tenant name, altough is
not required. Because, if you configured a `tenant_prefix`, this function will
return the prefixed one.
"""
import Mix.Ecto, only: [build_repo_priv: 1]
alias Ecto.{
Adapters.SQL,
Migrator
}
def config, do: struct(Triplex.Config, Application.get_all_env(:triplex))
@doc """
This function has been deprecated.
Use the `prefix` key in your `Repo` calls opts instead.
For example, instead of:
User |> Triplex.put_tenant("my_tenant") |> Repo.all()
User:
Repo.all(User, prefix: "my_tenant")
Don't forget to apply the `Triplex.to_prefix/1` to transform your tenant to
a prefix if you configured a `tenant_prefix` on your triplex configuration.
By default, you will not need it.
"""
def put_tenant(prefixable, map) when is_map(map) do
put_tenant(prefixable, tenant_field(map))
end
def put_tenant(prefixable, nil), do: prefixable
def put_tenant(%Ecto.Changeset{} = changeset, tenant) do
new_changes =
changeset.changes
|> Map.to_list
|> Enum.reduce(%{}, fn({key, value}, acc) ->
new_value = case {key, value} do
{_, list} when is_list(list) ->
Enum.map(list, &put_tenant(&1, tenant))
{_, %Ecto.Changeset{} = changeset} ->
put_tenant(changeset, tenant)
{_, value} ->
value
end
Map.put(acc, key, new_value)
end)
changeset = %{changeset | changes: new_changes}
%{changeset | data: put_tenant(changeset.data, tenant)}
end
def put_tenant(%{__struct__: _, __meta__: _} = schema, tenant) do
warn_put_tenant_deprecated()
schema
|> Map.to_list
|> Enum.reduce(%{}, fn({key, value}, acc) ->
new_value = case {key, value} do
{key, value} when key in [:__struct__, :__meta__] ->
value
{_, list} when is_list(list) ->
Enum.map(list, &put_tenant(&1, tenant))
{_, %{__struct__: _, __meta__: _} = struct} ->
put_tenant(struct, tenant)
{_, value} ->
value
end
Map.put(acc, key, new_value)
end)
|> Ecto.put_meta(prefix: to_prefix(tenant))
end
def put_tenant(queryable, tenant) do
warn_put_tenant_deprecated()
if Ecto.Queryable.impl_for(queryable) do
query = Ecto.Queryable.to_query(queryable)
Map.put(query, :prefix, to_prefix(tenant))
else
queryable
end
end
defp warn_put_tenant_deprecated() do
IO.warn("""
Triplex.put_tenant/2 is deprecated. Use the `prefix` key in your `Repo`
calls opts instead.
""")
end
@doc """
Execute the given function with the given tenant set.
"""
def with_tenant(tenant, func) do
IO.warn("""
Triplex.with_tenant/2 is deprecated due to its not concurrency safe nature
""")
old_tenant = current_tenant()
put_current_tenant(tenant)
try do
func.()
after
put_current_tenant(old_tenant)
end
end
@doc """
Return the current tenant, set by `put_current_tenant/1`.
"""
def current_tenant do
IO.warn("""
Triplex.current_tenant/0 is deprecated due to its not concurrency safe
nature
""")
Process.get(__MODULE__)
end
@doc """
Sets the current tenant in the current process.
"""
def put_current_tenant(nil) do
warn_put_current_tenant_deprecated()
Process.put(__MODULE__, nil)
end
def put_current_tenant(value) when is_binary(value) do
warn_put_current_tenant_deprecated()
Process.put __MODULE__, value
end
def put_current_tenant(_) do
raise ArgumentError, "put_current_tenant/1 only accepts binary tenants"
end
defp warn_put_current_tenant_deprecated() do
IO.warn("""
Triplex.put_current_tenant/1 is deprecated due to its not concurrency safe
nature
""")
end
@doc """
Returns the list of reserverd tenants.
By default, there are some limitations for the name of a tenant depending on
the database, like "public" or anything that start with "pg_".
You also can configure your own reserved tenant names if you want with:
config :triplex, reserved_tenants: ["www", "api", ~r/^db\d+$/]
Notice that you can use regexes, and they will be applied to the tenant
names.
"""
def reserved_tenants do
[nil, "public", "information_schema", ~r/^pg_/ |
config().reserved_tenants]
end
@doc """
Returns if the given tenant is reserved or not.
If the given tenant is a map, it will apply `tenant_field/1` to it and get
the prefix from the field.
"""
def reserved_tenant?(tenant) do
Enum.any? reserved_tenants(), fn (i) ->
if Regex.regex?(i) do
Regex.match?(i, to_prefix(tenant))
else
i == tenant
end
end
end
@doc """
Creates the given tenant on the given repo.
Besides creating the database itself, this function also loads their
structure executing all migrations from inside
`priv/YOUR_REPO/tenant_migrations` folder.
If the given tenant is a map, it will apply `tenant_field/1` to it and get
the prefix from the field.
If the repo is not given, it uses the one you configured.
"""
def create(tenant, repo \\ config().repo) do
tenant
|> to_prefix()
|> create_schema(repo, &(migrate(&1, &2)))
end
@doc """
Drops the given tenant on the given repo.
If the given tenant is a map, it will apply `tenant_field/1` to it and get
the prefix from the field.
If the repo is not given, it uses the one you configured.
"""
def drop(tenant, repo \\ config().repo) do
if reserved_tenant?(tenant) do
{:error, reserved_message(tenant)}
else
sql = "DROP SCHEMA \"#{to_prefix(tenant)}\" CASCADE"
case SQL.query(repo, sql, []) do
{:error, e} ->
{:error, Postgrex.Error.message(e)}
result -> result
end
end
end
@doc """
Renames the given tenant on the given repo.
If any given tenant is a map, it will apply `tenant_field/1` to it and get
the prefix from the field.
If the repo is not given, it uses the one you configured.
"""
def rename(old_tenant, new_tenant, repo \\ config().repo) do
if reserved_tenant?(new_tenant) do
{:error, reserved_message(new_tenant)}
else
sql = """
ALTER SCHEMA \"#{to_prefix(old_tenant)}\"
RENAME TO \"#{to_prefix(new_tenant)}\"
"""
case SQL.query(repo, sql, []) do
{:error, e} ->
{:error, Postgrex.Error.message(e)}
result -> result
end
end
end
@doc """
Returns all the tenants on the given repo.
If the given tenant is a map, it will apply `tenant_field/1` to it and get
the prefix from the field.
If the repo is not given, it uses the one you configured.
"""
def all(repo \\ config().repo) do
sql = """
SELECT schema_name
FROM information_schema.schemata
"""
%Postgrex.Result{rows: result} = SQL.query!(repo, sql, [])
result
|> List.flatten
|> Enum.filter(&(!reserved_tenant?(&1)))
end
@doc """
Returns if the tenant exists or not on the given repo.
If the given tenant is a map, it will apply `tenant_field/1` to it and get
the prefix from the field.
If the repo is not given, it uses the one you configured.
"""
def exists?(tenant, repo \\ config().repo) do
if reserved_tenant?(tenant) do
false
else
sql = """
SELECT COUNT(*)
FROM information_schema.schemata
WHERE schema_name = $1
"""
%Postgrex.Result{rows: [[count]]} =
SQL.query!(repo, sql, [to_prefix(tenant)])
count == 1
end
end
@doc """
Migrates the given tenant.
If the given tenant is a map, it will apply `tenant_field/1` to it and get
the prefix from the field.
If the repo is not given, it uses the one you configured.
"""
def migrate(tenant, repo \\ config().repo) do
try do
{:ok, Migrator.run(repo, migrations_path(repo), :up,
all: true,
prefix: to_prefix(tenant))}
rescue
e in Postgrex.Error ->
{:error, Postgrex.Error.message(e)}
end
end
@doc """
Return the path for your tenant migrations.
If the repo is not given, it uses the one you configured.
"""
def migrations_path(repo \\ config().repo) do
if repo do
Path.join(build_repo_priv(repo), "tenant_migrations")
else
""
end
end
@doc """
Creates the tenant schema/database on the given repo.
After creating it successfully, the given function callback is called with
the tenant and the repo as arguments.
If the given tenant is a map, it will apply `tenant_field/1` to it and get
the prefix from the field.
If the repo is not given, it uses the one you configured.
"""
def create_schema(tenant, repo \\ config().repo, func \\ nil) do
if reserved_tenant?(tenant) do
{:error, reserved_message(tenant)}
else
case SQL.query(repo, "CREATE SCHEMA \"#{to_prefix(tenant)}\"", []) do
{:ok, _} = result ->
if func do
func.(tenant, repo)
else
result
end
{:error, e} ->
{:error, Postgrex.Error.message(e)}
end
end
end
@doc """
Returns the tenant name with the given prefix.
If the prefix is omitted, the `tenant_prefix` configuration will be used.
The tenant can be a string, a map or a struct. For a string it will
be used as the tenant name to concat the prefix. For a map or a struct, it
will get the `tenant_field/0` from it to concat the prefix.
"""
def to_prefix(tenant, prefix \\ config().tenant_prefix)
def to_prefix(map, prefix) when is_map(map) do
map
|> tenant_field()
|> to_prefix(prefix)
end
def to_prefix(tenant, nil), do: tenant
def to_prefix(tenant, prefix), do: prefix <> tenant
@doc """
Returns the value of the configured tenant field on the given map.
"""
def tenant_field(map) do
Map.get(map, config().tenant_field)
end
defp reserved_message(tenant) do
"""
You cannot create the schema because \"#{inspect(tenant)}\" is a reserved
tenant
"""
end
end