Current section
Files
Jump to
Current section
Files
lib/selecto/tenant.ex
defmodule Selecto.Tenant do
@moduledoc """
Multi-tenant helpers for Selecto query state.
This module provides a lightweight tenant context contract for read-path
queries. Tenant scope is enforced by appending tenant constraints to the
query's required filter bucket (`set.required_filters`).
"""
@default_tenant_field "tenant_id"
@default_namespace "tenant"
@type tenant_context :: %{
optional(:tenant_id) => term(),
optional(:tenant_mode) => atom() | String.t(),
optional(:tenant_field) => atom() | String.t(),
optional(:prefix) => String.t(),
optional(:namespace) => String.t(),
optional(:required) => boolean(),
optional(:required_filters) => [Selecto.Types.filter()]
}
@doc """
Attach tenant context to a Selecto query.
"""
@spec with_tenant(Selecto.Types.t(), tenant_context() | keyword() | String.t() | atom() | nil) ::
Selecto.Types.t()
def with_tenant(selecto, tenant_context) do
%{selecto | tenant: normalize_context(tenant_context)}
end
@doc """
Read the tenant context from a Selecto query.
"""
@spec tenant(Selecto.Types.t()) :: tenant_context() | nil
def tenant(%{tenant: tenant_context}), do: tenant_context
def tenant(_), do: nil
@doc """
Apply tenant scope to a query's required filter bucket.
Options:
- `:tenant` - explicit tenant context override
- `:tenant_id` - explicit tenant id override
- `:tenant_field` - explicit tenant field override
- `:required_filters` - additional required filters
"""
@spec apply_tenant_scope(Selecto.Types.t(), keyword()) :: Selecto.Types.t()
def apply_tenant_scope(selecto, opts \\ []) do
tenant_context =
opts
|> Keyword.get(:tenant, tenant(selecto))
|> normalize_context()
case tenant_context do
nil ->
selecto
normalized_context ->
selecto = %{selecto | tenant: normalized_context}
additional_required =
List.wrap(Map.get(normalized_context, :required_filters, [])) ++
List.wrap(Keyword.get(opts, :required_filters, []))
selecto =
Enum.reduce(additional_required, selecto, fn filter, acc ->
require_tenant_filter(acc, filter)
end)
tenant_field =
Keyword.get(
opts,
:tenant_field,
Map.get(normalized_context, :tenant_field, @default_tenant_field)
)
tenant_id = Keyword.get(opts, :tenant_id, Map.get(normalized_context, :tenant_id))
if is_nil(tenant_id) do
selecto
else
require_tenant_filter(selecto, tenant_field, tenant_id)
end
end
end
@doc """
Append a required filter to the query set.
"""
@spec require_tenant_filter(Selecto.Types.t(), Selecto.Types.filter()) :: Selecto.Types.t()
def require_tenant_filter(selecto, filter) do
set = Map.get(selecto, :set, %{})
current = Map.get(set, :required_filters, [])
updated = uniq_filters(current ++ [filter])
%{selecto | set: Map.put(set, :required_filters, updated)}
end
@doc """
Append a required tenant filter from field + value.
"""
@spec require_tenant_filter(Selecto.Types.t(), atom() | String.t(), term()) :: Selecto.Types.t()
def require_tenant_filter(selecto, tenant_field, tenant_id) do
require_tenant_filter(selecto, {normalize_field(tenant_field), tenant_id})
end
@doc """
Return whether tenant scope is required for this query.
Precedence:
1. `opts[:require_tenant]`
2. tenant context `:required` / `:require_tenant`
3. domain `:tenant_required` / `:require_tenant`
4. inferred true for `tenant_mode` in shared-column/shared-rls/schema modes
"""
@spec tenant_required?(Selecto.Types.t(), keyword()) :: boolean()
def tenant_required?(selecto, opts \\ []) do
context = tenant(selecto) || %{}
domain = Map.get(selecto, :domain, %{})
cond do
is_boolean(Keyword.get(opts, :require_tenant)) ->
Keyword.get(opts, :require_tenant)
is_boolean(map_get(context, :required)) ->
map_get(context, :required)
is_boolean(map_get(context, :require_tenant)) ->
map_get(context, :require_tenant)
is_boolean(map_get(domain, :tenant_required)) ->
map_get(domain, :tenant_required)
is_boolean(map_get(domain, :require_tenant)) ->
map_get(domain, :require_tenant)
true ->
tenant_mode_requires_scope?(map_get(context, :tenant_mode))
end
end
@doc """
Validate tenant scope requirements for read and derivation paths.
Returns `:ok` when tenant scope is optional or present. Returns structured
validation error when tenant scope is required but missing.
"""
@spec validate_scope(Selecto.Types.t(), keyword()) :: :ok | {:error, Selecto.Error.t()}
def validate_scope(selecto, opts \\ []) do
if tenant_required?(selecto, opts) and not has_tenant_scope?(selecto, opts) do
tenant_context = tenant(selecto) || %{}
{:error,
Selecto.Error.validation_error("Tenant scope is required but missing", %{
tenant_mode: map_get(tenant_context, :tenant_mode),
tenant_field: tenant_field(selecto, opts),
tenant_id: map_get(tenant_context, :tenant_id),
prefix: map_get(tenant_context, :prefix)
})}
else
:ok
end
end
@doc """
Raise when tenant scope is required and missing.
"""
@spec ensure_scope!(Selecto.Types.t(), keyword()) :: :ok
def ensure_scope!(selecto, opts \\ []) do
case validate_scope(selecto, opts) do
:ok -> :ok
{:error, error} -> raise Selecto.Error.to_exception(error)
end
end
@doc """
Merge execution options with tenant-derived defaults.
If no `:prefix` option is provided explicitly and tenant context includes a
`:prefix`, the prefix is injected into execution opts.
"""
@spec merge_execution_opts(Selecto.Types.t(), keyword()) :: keyword()
def merge_execution_opts(selecto, opts \\ []) do
if Keyword.has_key?(opts, :prefix) do
opts
else
case tenant(selecto) do
%{prefix: prefix} when is_binary(prefix) and byte_size(prefix) > 0 ->
Keyword.put(opts, :prefix, prefix)
_ ->
opts
end
end
end
@doc """
Normalize tenant context input into a map with atom keys.
"""
@spec normalize_context(tenant_context() | keyword() | String.t() | atom() | nil) ::
tenant_context() | nil
def normalize_context(nil), do: nil
def normalize_context(tenant_id) when is_binary(tenant_id) or is_atom(tenant_id) do
%{
tenant_id: to_string(tenant_id),
tenant_field: @default_tenant_field,
namespace: @default_namespace,
required_filters: []
}
end
def normalize_context(tenant_context) when is_list(tenant_context) do
tenant_context
|> Enum.into(%{})
|> normalize_context()
end
def normalize_context(tenant_context) when is_map(tenant_context) do
tenant_id = map_get(tenant_context, :tenant_id) || map_get(tenant_context, :id)
tenant_field =
tenant_context
|> map_get(:tenant_field)
|> normalize_field()
%{
tenant_id: tenant_id,
tenant_mode: map_get(tenant_context, :tenant_mode),
tenant_field: tenant_field,
prefix: map_get(tenant_context, :prefix),
namespace: map_get(tenant_context, :namespace) || @default_namespace,
required:
case map_get(tenant_context, :required) do
nil -> map_get(tenant_context, :require_tenant)
value -> value
end,
required_filters: List.wrap(map_get(tenant_context, :required_filters) || [])
}
end
def normalize_context(_), do: nil
defp map_get(map, key) when is_map(map) and is_atom(key) do
Map.get(map, key) || Map.get(map, Atom.to_string(key))
end
defp normalize_field(nil), do: @default_tenant_field
defp normalize_field(field) when is_atom(field), do: Atom.to_string(field)
defp normalize_field(field) when is_binary(field), do: field
defp normalize_field(field), do: to_string(field)
defp tenant_mode_requires_scope?(mode) do
normalized = mode |> to_string() |> String.downcase()
normalized in ["shared_column", "shared_rls", "schema"]
end
defp has_tenant_scope?(selecto, opts) do
tenant_context = tenant(selecto) || %{}
field = tenant_field(selecto, opts)
required = required_filter_scope?(selecto, field)
has_prefix = map_get(tenant_context, :prefix) |> present_string?()
has_id = not is_nil(map_get(tenant_context, :tenant_id))
required or has_prefix or has_id
end
defp tenant_field(selecto, opts) do
tenant_context = tenant(selecto) || %{}
opts
|> Keyword.get(:tenant_field, map_get(tenant_context, :tenant_field) || @default_tenant_field)
|> normalize_field()
end
defp required_filter_scope?(selecto, tenant_field) do
set_required =
selecto
|> Map.get(:set, %{})
|> Map.get(:required_filters, [])
domain_required =
selecto
|> Map.get(:domain, %{})
|> Map.get(:required_filters, [])
(set_required ++ domain_required)
|> Enum.any?(fn
{field, value} -> normalize_field(field) == tenant_field and not is_nil(value)
_ -> false
end)
end
defp present_string?(value) when is_binary(value), do: byte_size(value) > 0
defp present_string?(_), do: false
defp uniq_filters(filters) do
Enum.reduce(filters, [], fn filter, acc ->
if filter in acc do
acc
else
acc ++ [filter]
end
end)
end
end