Current section
Files
Jump to
Current section
Files
lib/tenant.ex
defmodule Tenant do
@moduledoc """
This is the main module of Tenant.
The main objective 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: Tenant.to_prefix("my_tenant"))
It's a good idea to call `Tenant.to_prefix` on your tenant name, although is
not required. Because, if you configured a `tenant_prefix`, this function will
return the prefixed one.
"""
alias Ecto.Adapters.SQL
alias Ecto.Migrator
@doc """
Returns a `%Tenant.Config{}` struct with all the args loaded from the app
configuration.
"""
def config, do: struct(Tenant.Config, Application.get_all_env(:tenant))
@doc """
Returns the list of reserved 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 :tenant, 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",
"performance_schema",
"sys",
"mysql",
~r/^pg_/
| config().reserved_tenants
]
end
@doc """
Returns if the given `tenant` is reserved or not.
The function `to_prefix/1` will be applied to the tenant.
"""
def reserved_tenant?(tenant) when is_map(tenant) do
tenant
|> tenant_field()
|> reserved_tenant?()
end
def reserved_tenant?(tenant) do
do_reserved_tenant?(tenant) or
tenant
|> to_prefix()
|> do_reserved_tenant?()
end
defp do_reserved_tenant?(prefix) do
Enum.any?(reserved_tenants(), fn i ->
if is_bitstring(prefix) and is_struct(i, Regex) do
Regex.match?(i, prefix)
else
i == prefix
end
end)
end
@doc """
Creates the given `tenant` on the given `repo`.
Returns `{:ok, tenant}` if successful or `{:error, reason}` otherwise.
Besides creating the database itself, this function also loads their
structure executing all migrations from inside
`priv/YOUR_REPO/tenant_migrations` folder. By calling `create_schema/3`
sending `migrate/2` as the `func` callback.
See `migrate/2` for more details about the migration running.
### Ecto 3 migrations, tenant and transactions
So on Ecto 3, migrations were changed to run on async tasks. Because of
that it's not possible anymore to run `Tenant.create/2` inside of a
transaction anymore.
But there is a way to achieve the same results using `create_schema/3`
and `migrate/2`. Here is an example using transaction:
Repo.transaction(fn ->
{:ok, _} = Tenant.create("tenant")
User.insert!(%{name: "Demo user 1"})
User.insert!(%{name: "Demo user 2"})
end)
And here is how you could achieve the same results on success or fail:
Tenant.create_schema("tenant", Repo, fn(tenant, repo) ->
Repo.transaction(fn ->
{:ok, _} = Tenant.migrate(tenant, repo)
User.insert!(%{name: "Demo user 1"})
User.insert!(%{name: "Demo user 2"})
# the `create_schema/3` function must return `{:ok, "tenant"}`
# if succeeded, and `Repo.transaction` transforms the function results
# on this tuple
tenant
end)
end)
So, if the function given to `create_schema/3` returns an error tuple, it will
rollback the created schema and return that tuple to you. Check out
`create_schema/3` docs for more details.
"""
def create(tenant, repo \\ config().repo) do
create_schema(tenant, repo, &migrate(&1, &2))
end
@doc """
Creates the `tenant` schema/database on the given `repo`.
Returns `{:ok, tenant}` if successful or `{:error, reason}` otherwise.
After creating it successfully, the given `func` callback is called with
the `tenant` and the `repo` as arguments. The `func` must return
`{:ok, any}` if successful or `{:error, reason}` otherwise. In the case
the `func` fails, this func will rollback the created schema and
fail with the same `reason`.
The function `to_prefix/1` will be applied to the `tenant`.
"""
def create_schema(tenant, repo \\ config().repo, func \\ nil) do
if reserved_tenant?(tenant) do
{:error, reserved_message(tenant)}
else
sql =
case repo.__adapter__() do
Ecto.Adapters.MyXQL -> "CREATE DATABASE `#{to_prefix(tenant)}`"
Ecto.Adapters.Postgres -> "CREATE SCHEMA \"#{to_prefix(tenant)}\""
end
case SQL.query(repo, sql, []) do
{:ok, _} ->
with {:ok, _} <- add_to_tenants_table(tenant, repo),
{:ok, _} <- exec_func(func, tenant, repo) do
{:ok, tenant}
else
{:error, reason} ->
drop(tenant, repo)
{:error, error_message(reason)}
end
{:error, reason} ->
{:error, error_message(reason)}
end
end
end
defp error_message(msg) do
if is_exception(msg) do
Exception.message(msg)
else
msg
end
end
defp add_to_tenants_table(tenant, repo) do
case repo.__adapter__() do
Ecto.Adapters.MyXQL ->
if Tenant.config().tenant_table == :"information_schema.schemata" do
{:ok, :skipped}
else
sql = "INSERT INTO #{Tenant.config().tenant_table} (name) VALUES (?)"
SQL.query(repo, sql, [to_prefix(tenant)])
end
Ecto.Adapters.Postgres ->
{:ok, :skipped}
end
end
defp remove_from_tenants_table(tenant, repo) do
case repo.__adapter__() do
Ecto.Adapters.MyXQL ->
if Tenant.config().tenant_table == :"information_schema.schemata" do
{:ok, :skipped}
else
SQL.query(repo, "DELETE FROM #{Tenant.config().tenant_table} WHERE NAME = ?", [tenant])
end
Ecto.Adapters.Postgres ->
{:ok, :skipped}
end
end
defp exec_func(nil, tenant, _) do
{:ok, tenant}
end
defp exec_func(func, tenant, repo) when is_function(func) do
case func.(tenant, repo) do
{:ok, _} -> {:ok, tenant}
{:error, msg} -> {:error, msg}
end
end
@doc """
Drops the given tenant on the given `repo`.
Returns `{:ok, tenant}` if successful or `{:error, reason}` otherwise.
The function `to_prefix/1` will be applied to the `tenant`.
"""
def drop(tenant, repo \\ config().repo) do
if reserved_tenant?(tenant) do
{:error, reserved_message(tenant)}
else
sql =
case repo.__adapter__() do
Ecto.Adapters.MyXQL -> "DROP DATABASE `#{to_prefix(tenant)}`"
Ecto.Adapters.Postgres -> "DROP SCHEMA \"#{to_prefix(tenant)}\" CASCADE"
end
with {:ok, _} <- SQL.query(repo, sql, []),
{:ok, _} <- remove_from_tenants_table(to_prefix(tenant), repo) do
{:ok, tenant}
else
{:error, exception} ->
{:error, error_message(exception)}
end
end
end
@doc """
Renames the `old_tenant` to the `new_tenant` on the given `repo`.
Returns `{:ok, new_tenant}` if successful or `{:error, reason}` otherwise.
The function `to_prefix/1` will be applied to the `old_tenant` and
`new_tenant`.
"""
def rename(old_tenant, new_tenant, repo \\ config().repo) do
if reserved_tenant?(new_tenant) do
{:error, reserved_message(new_tenant)}
else
case repo.__adapter__() do
Ecto.Adapters.MyXQL ->
{:error, "you cannot rename tenants in a MySQL database."}
Ecto.Adapters.Postgres ->
sql = """
ALTER SCHEMA \"#{to_prefix(old_tenant)}\"
RENAME TO \"#{to_prefix(new_tenant)}\"
"""
case SQL.query(repo, sql, []) do
{:ok, _} ->
{:ok, new_tenant}
{:error, message} ->
{:error, error_message(message)}
end
end
end
end
@doc """
Returns all the tenants on the given `repo`.
"""
def all(repo \\ config().repo, opts \\ []) do
sql =
case repo.__adapter__() do
Ecto.Adapters.MyXQL ->
column_name =
if Tenant.config().tenant_table == :"information_schema.schemata",
do: "schema_name",
else: "name"
"SELECT #{column_name} FROM `#{config().tenant_table}`"
Ecto.Adapters.Postgres ->
"""
SELECT schema_name
FROM information_schema.schemata
"""
end
%{rows: result} = SQL.query!(repo, sql, [], opts)
prefix = config().tenant_prefix
result
|> List.flatten()
|> Enum.filter(fn tenant_name ->
!reserved_tenant?(tenant_name) and
(prefix == nil or String.starts_with?(tenant_name, prefix))
end)
|> Enum.map(fn tenant_name ->
case prefix do
nil -> tenant_name
prefix -> String.replace_prefix(tenant_name, prefix, "")
end
end)
end
@doc """
Returns if the given `tenant` exists or not on the given `repo`.
The function `to_prefix/1` will be applied to the `tenant`.
"""
def exists?(tenant, repo \\ config().repo) do
if reserved_tenant?(tenant) do
false
else
sql =
case repo.__adapter__() do
Ecto.Adapters.MyXQL ->
"SELECT COUNT(*) FROM `#{config().tenant_table}` WHERE name = ?"
Ecto.Adapters.Postgres ->
"""
SELECT COUNT(*)
FROM information_schema.schemata
WHERE schema_name = $1
"""
end
%{rows: [[count]]} = SQL.query!(repo, sql, [to_prefix(tenant)])
count == 1
end
end
@doc """
Migrates the given `tenant` on your `repo`.
Returns `{:ok, migrated_versions}` if successful or `{:error, reason}` otherwise.
The function `to_prefix/1` will be applied to the `tenant`.
"""
def migrate(tenant, repo \\ config().repo) do
Code.compiler_options(ignore_module_conflict: true)
try do
migrated_versions =
Migrator.run(
repo,
migrations_path(repo),
:up,
all: true,
prefix: to_prefix(tenant)
)
{:ok, migrated_versions}
rescue
exception ->
{:error, error_message(exception)}
after
Code.compiler_options(ignore_module_conflict: false)
end
end
@doc """
Returns the path for the tenant migrations on your `repo`.
"""
def migrations_path(repo \\ config().repo) do
repo
|> Migrator.migrations_path()
|> Path.join("..")
|> Path.join(config().migrations_path)
|> Path.expand()
end
@doc """
Returns the `tenant` name with the given `prefix`.
If the `prefix` is omitted, the `tenant_prefix` configuration from
`Tenant.Config` 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(tenant, prefix) when is_map(tenant) do
tenant
|> 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