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.
"""
alias Ecto.Adapters.SQL
alias Ecto.Migrator
alias Postgrex.Error, as: PGError
@doc """
Returns a `%Triplex.Config{}` struct with all the args loaded from the app
configuration.
"""
def config, do: struct(Triplex.Config, Application.get_all_env(:triplex))
@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.
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 Regex.regex?(i) 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.
"""
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 successfull or `{:error, reason}` otherwise. In the case
the `func` fails, this func will 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 = "CREATE SCHEMA \"#{to_prefix(tenant)}\""
with {:ok, _} <- SQL.query(repo, sql, []),
{:ok, _} <- exec_func(func, tenant, repo) do
{:ok, tenant}
else
{:error, %PGError{} = e} -> {:error, PGError.message(e)}
{:error, msg} -> {:error, msg}
end
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 = "DROP SCHEMA \"#{to_prefix(tenant)}\" CASCADE"
case SQL.query(repo, sql, []) do
{:ok, _} ->
{:ok, tenant}
{:error, e} ->
{:error, PGError.message(e)}
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
sql = """
ALTER SCHEMA \"#{to_prefix(old_tenant)}\"
RENAME TO \"#{to_prefix(new_tenant)}\"
"""
case SQL.query(repo, sql, []) do
{:ok, _} ->
{:ok, new_tenant}
{:error, e} ->
{:error, PGError.message(e)}
end
end
end
@doc """
Returns all the tenants on the given `repo`.
"""
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 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 = """
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` 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
e in PGError ->
{:error, PGError.message(e)}
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
path =
repo.config()
|> Keyword.get(:priv, "priv/#{repo |> Module.split |> List.last |> Macro.underscore}")
|> Path.join("tenant_migrations")
repo.config()
|> Keyword.get(:otp_app)
|> Application.app_dir(path)
end
@doc """
Returns the `tenant` name with the given `prefix`.
If the `prefix` is omitted, the `tenant_prefix` configuration from
`Triplex.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