defmodule Selecto.Config.Overlay do
@moduledoc """
Merges overlay configurations into base domain configurations.
Overlays provide a flexible way to customize domain configurations at compile-time
or runtime without modifying the base configuration. This is particularly useful for:
- Separating generated code from user customizations
- Multi-tenant applications with per-tenant configurations
- Feature flags that change available filters or columns
- A/B testing different domain configurations
- Environment-specific domain customizations
## Merge Strategy
- **Column configurations**: Deep merge - overlay extends/overrides base column properties
- **Filters**: Additive merge - both base and overlay filters are available
- **Functions**: Deep merge - overlay can add or override named UDF specs
- **Query members** (`query_members.ctes/values/subqueries`): Deep merge - overlay can
add or override named query-member presets without replacing the full registry
- **Schemas** (`schemas`): Deep merge - overlay can add/override schema entries
without replacing the full schemas map
- **Joins** (`joins`): Deep merge - overlay can add/override join entries
without replacing the full joins map
- **Source associations** (`source.associations`): Deep merge - overlay can add
or override root associations without replacing the full source map
- **Redact fields**: Union - unique list of all redacted fields
- **Other fields**: Shallow merge - overlay takes precedence
## Examples
### Basic Overlay Merge
base = %{
source: %{
columns: %{
price: %{type: :decimal}
},
redact_fields: []
},
filters: %{}
}
overlay = %{
columns: %{
price: %{
label: "Product Price",
format: :currency
}
},
redact_fields: [:internal_notes]
}
merged = Selecto.Config.Overlay.merge(base, overlay)
# => %{
# source: %{
# columns: %{
# price: %{type: :decimal, label: "Product Price", format: :currency}
# },
# redact_fields: [:internal_notes]
# },
# filters: %{}
# }
### Runtime Multi-Tenant Configuration
defmodule MyApp.ProductDomain do
def domain(tenant_id) do
base_domain()
|> Selecto.Config.Overlay.merge(tenant_overlay(tenant_id))
end
defp tenant_overlay("premium"), do: %{
columns: %{price: %{format: :currency_with_symbol}}
}
defp tenant_overlay(_), do: %{}
end
### Compile-Time Customization (Generated Domains)
# Generated by selecto_mix
defmodule MyApp.SelectoDomains.ProductDomain do
def domain do
base_domain()
|> Selecto.Config.Overlay.merge(overlay())
end
# Generated base configuration
defp base_domain, do: %{...}
# User-defined overlay
defp overlay do
if Code.ensure_loaded?(MyApp.SelectoDomains.Overlays.ProductDomainOverlay) do
MyApp.SelectoDomains.Overlays.ProductDomainOverlay.overlay()
else
%{}
end
end
end
"""
@doc """
Merges an overlay configuration into a base domain configuration.
The overlay is intelligently merged based on the semantics of each configuration key:
- Columns are deep-merged to allow fine-grained property overrides
- Filters are combined additively
- Redact fields are unioned
- Other fields use overlay value if present
Returns the merged configuration map.
## Parameters
- `base` - The base domain configuration map
- `overlay` - The overlay configuration map to merge in
## Examples
iex> base = %{source: %{columns: %{id: %{type: :integer}}, redact_fields: []}}
iex> overlay = %{columns: %{id: %{label: "ID"}}}
iex> Selecto.Config.Overlay.merge(base, overlay)
%{source: %{columns: %{id: %{type: :integer, label: "ID"}}, redact_fields: []}}
"""
def merge(base, overlay) when is_map(base) and is_map(overlay) do
base
|> merge_columns(overlay)
|> merge_jsonb_schemas(overlay)
|> merge_filters(overlay)
|> merge_functions(overlay)
|> merge_detail_actions(overlay)
|> merge_query_members(overlay)
|> merge_schemas(overlay)
|> merge_joins(overlay)
|> merge_source_associations(overlay)
|> merge_redact_fields(overlay)
|> merge_other_fields(overlay)
end
def merge(base, _overlay) when is_map(base) do
# No overlay or invalid overlay, return base as-is
base
end
# Merges column configurations from overlay into base.
#
# Column configurations in the overlay are deeply merged with base columns.
# This allows overlays to extend or override specific column properties
# without replacing the entire column configuration.
defp merge_columns(base, overlay) do
overlay_columns = get_in(overlay, [:columns]) || %{}
if map_size(overlay_columns) > 0 do
# Deep merge overlay columns into base.source.columns
update_in(base, [:source, :columns], fn base_columns ->
base_columns = base_columns || %{}
deep_merge(base_columns, overlay_columns)
end)
else
base
end
end
# Merges JSONB schema definitions from overlay into base columns.
#
# For each JSONB schema defined in the overlay, the schema is added
# to the corresponding column's configuration. This replaces the
# `schema: :stub` placeholder with the actual schema.
defp merge_jsonb_schemas(base, overlay) do
overlay_jsonb_schemas = get_in(overlay, [:jsonb_schemas]) || %{}
if map_size(overlay_jsonb_schemas) > 0 do
Enum.reduce(overlay_jsonb_schemas, base, fn {column_name, schema}, acc ->
# Update the column configuration with the JSONB schema
update_in(acc, [:source, :columns, column_name], fn column_config ->
column_config = column_config || %{}
Map.put(column_config, :schema, schema)
end)
end)
else
base
end
end
# Merges filter configurations from overlay into base.
#
# Overlay filters extend the base filters - both are available.
# If there's a conflict, overlay filter takes precedence.
defp merge_filters(base, overlay) do
overlay_filters = get_in(overlay, [:filters]) || %{}
if map_size(overlay_filters) > 0 do
update_in(base, [:filters], fn base_filters ->
base_filters = base_filters || %{}
Map.merge(base_filters, overlay_filters)
end)
else
base
end
end
defp merge_functions(base, overlay) do
overlay_functions = get_in(overlay, [:functions]) || %{}
if map_size(overlay_functions) > 0 do
update_in(base, [:functions], fn base_functions ->
base_functions = base_functions || %{}
deep_merge(base_functions, overlay_functions)
end)
else
base
end
end
# Merges detail-row action definitions from overlay into base.
#
# Detail actions are deep-merged so overlays can add or override individual
# action definitions without replacing sibling actions.
defp merge_detail_actions(base, overlay) do
overlay_detail_actions = get_in(overlay, [:detail_actions]) || %{}
if map_size(overlay_detail_actions) > 0 do
update_in(base, [:detail_actions], fn base_detail_actions ->
base_detail_actions = base_detail_actions || %{}
deep_merge(base_detail_actions, overlay_detail_actions)
end)
else
base
end
end
# Merges redact_fields from overlay into base.
#
# The result is a union of both lists (no duplicates).
# Also updates the source.redact_fields for consistency.
defp merge_redact_fields(base, overlay) do
overlay_redact = get_in(overlay, [:redact_fields]) || []
if length(overlay_redact) > 0 do
base
|> update_in([:source, :redact_fields], fn base_redact ->
base_redact = base_redact || []
Enum.uniq(base_redact ++ overlay_redact)
end)
else
base
end
end
# Merges named query members from overlay into base.
#
# Query members include named CTE, VALUES, and subquery presets. They are
# deep-merged so overlays can add or override specific members while keeping
# the rest of the base registry intact.
defp merge_query_members(base, overlay) do
overlay_query_members = get_in(overlay, [:query_members])
cond do
is_map(overlay_query_members) and map_size(overlay_query_members) > 0 ->
update_in(base, [:query_members], fn base_query_members ->
base_query_members = if is_map(base_query_members), do: base_query_members, else: %{}
deep_merge(base_query_members, overlay_query_members)
end)
true ->
base
end
end
# Merges top-level schema definitions from overlay into base.
#
# Schema entries are deep-merged so overlays can add or override one schema
# without replacing sibling schemas defined by the base domain.
defp merge_schemas(base, overlay) do
overlay_schemas = get_in(overlay, [:schemas])
cond do
is_map(overlay_schemas) and map_size(overlay_schemas) > 0 ->
update_in(base, [:schemas], fn base_schemas ->
base_schemas = if is_map(base_schemas), do: base_schemas, else: %{}
deep_merge(base_schemas, overlay_schemas)
end)
true ->
base
end
end
# Merges top-level join definitions from overlay into base.
#
# Join entries are deep-merged so overlays can add or override one join
# without replacing sibling joins defined by the base domain.
defp merge_joins(base, overlay) do
overlay_joins = get_in(overlay, [:joins])
cond do
is_map(overlay_joins) and map_size(overlay_joins) > 0 ->
update_in(base, [:joins], fn base_joins ->
base_joins = if is_map(base_joins), do: base_joins, else: %{}
deep_merge(base_joins, overlay_joins)
end)
true ->
base
end
end
# Merges root source associations from overlay into base.
#
# Source associations are deep-merged so overlays can extend the source graph
# without replacing sibling source settings such as source_table or fields.
defp merge_source_associations(base, overlay) do
overlay_source_associations = get_in(overlay, [:source, :associations])
cond do
is_map(overlay_source_associations) and map_size(overlay_source_associations) > 0 ->
update_in(base, [:source, :associations], fn base_source_associations ->
base_source_associations =
if is_map(base_source_associations), do: base_source_associations, else: %{}
deep_merge(base_source_associations, overlay_source_associations)
end)
true ->
base
end
end
# Merges other overlay fields that aren't handled specially.
#
# These fields use shallow merge - overlay replaces base value entirely.
# Skip special fields that have their own merge logic.
defp merge_other_fields(base, overlay) do
skip_fields = [
:columns,
:filters,
:functions,
:detail_actions,
:redact_fields,
:jsonb_schemas,
:query_members,
:schemas,
:joins,
:source
]
overlay
|> Map.drop(skip_fields)
|> Enum.reduce(base, fn {key, value}, acc ->
Map.put(acc, key, value)
end)
end
# Deep merges two maps recursively.
#
# When both values are maps, recursively merges them.
# Otherwise, the value from map2 takes precedence.
defp deep_merge(map1, map2) when is_map(map1) and is_map(map2) do
Map.merge(map1, map2, fn _key, val1, val2 ->
if is_map(val1) and is_map(val2) do
deep_merge(val1, val2)
else
val2
end
end)
end
defp deep_merge(_map1, map2), do: map2
end