defmodule Selecto.AutoRetarget do
@moduledoc """
Automatic retarget detection and application for Selecto queries.
When querying with fields from joined tables only (no source table fields),
this module can automatically retarget the query to use the joined table as the
source, which can be more efficient.
Auto-retarget is typically used with `prevent_denormalization` mode in detail views.
Aggregate views handle joins naturally through GROUP BY and don't need retargeting.
"""
require Logger
@doc """
Check if a selecto query should be automatically retargeted based on selected columns.
Returns the retargeted selecto if pivot is needed, otherwise returns the original.
## Options
- `:view_mode` - View type ("detail", "aggregate", "graph"). Only "detail" allows retargeting.
## Examples
# Will pivot if only category fields are selected
selecto = Selecto.configure(domain, conn)
retargeted = Selecto.AutoRetarget.maybe_apply(selecto, selected: ["category.name"], view_mode: "detail")
"""
@spec maybe_apply(Selecto.Types.t(), keyword()) :: Selecto.Types.t()
def maybe_apply(selecto, opts \\ []) do
view_mode = Keyword.get(opts, :view_mode, "detail")
selected_columns = Keyword.get(opts, :selected, [])
# Auto-retarget should only be used for detail views, not aggregate views
# Aggregate views handle joins naturally through GROUP BY
if view_mode != "aggregate" do
if should_retarget?(selecto, selected_columns) do
target_table = find_retarget_target(selecto, selected_columns)
if target_table do
# Find the join path to the target table
join_path = find_join_path(selecto.domain, target_table)
if join_path do
apply_retarget(selecto, target_table, join_path)
else
Logger.warning("Could not find join path to #{target_table}, skipping retarget")
selecto
end
else
selecto
end
else
selecto
end
else
Logger.debug("Skipping auto-retarget for aggregate view")
selecto
end
end
@doc """
Determine if a query should be retargeted based on selected columns.
Retargeting is beneficial when:
1. There are qualified columns from other tables
2. NO source table columns are selected (they wouldn't be available after retarget)
3. All qualified columns are from tables accessible from a single retarget target
"""
@spec should_retarget?(Selecto.Types.t(), [String.t() | atom()]) :: boolean()
def should_retarget?(selecto, selected_columns) do
source_columns = get_source_columns(selecto)
# Categorize columns into source vs joined tables
{source_cols, qualified_cols_by_table} = categorize_columns(selected_columns, source_columns)
# Only pivot if there are joined table columns and no source columns
case Map.keys(qualified_cols_by_table) do
[] ->
false
_table_names ->
source_cols == []
end
end
@doc """
Find the best table to retarget to based on selected columns.
Returns the table name (as atom) that should become the new source table.
"""
@spec find_retarget_target(Selecto.Types.t(), [String.t() | atom()]) :: atom() | nil
def find_retarget_target(selecto, selected_columns) do
source_columns = get_source_columns(selecto)
{_source_cols, qualified_cols_by_table} = categorize_columns(selected_columns, source_columns)
case Map.keys(qualified_cols_by_table) do
[] ->
nil
[single_table] ->
String.to_atom(single_table)
[first_table | _rest] ->
# For multiple tables, pivot to the first one
# TODO: Could be smarter about choosing the "root" table
String.to_atom(first_table)
end
end
# Private functions
defp categorize_columns(selected_columns, source_columns) do
Enum.reduce(selected_columns, {[], %{}}, fn col, {src, qualified} ->
col_str = to_string(col)
if String.contains?(col_str, ".") do
# Qualified column like "category.name"
[table_name, _column_name] = String.split(col_str, ".", parts: 2)
if table_name in ["selecto_root", ""] do
# It's a source column with qualification
{[col_str | src], qualified}
else
# Group by table name
current = Map.get(qualified, table_name, [])
{src, Map.put(qualified, table_name, [col_str | current])}
end
else
# Unqualified column - check if it's from source
if column_exists_in_source?(col, source_columns) do
{[col_str | src], qualified}
else
# Unknown origin, can't determine
{src, qualified}
end
end
end)
end
defp get_source_columns(selecto) do
source_config = selecto.domain.source
source_config
|> Map.get(:columns, %{})
|> Map.keys()
end
defp column_exists_in_source?(column_name, source_columns) do
col_atom =
if is_binary(column_name), do: String.to_existing_atom(column_name), else: column_name
col_string = if is_atom(column_name), do: Atom.to_string(column_name), else: column_name
Enum.any?(source_columns, fn source_col ->
source_col == col_atom or source_col == col_string or
Atom.to_string(source_col) == col_string or
String.to_atom(to_string(source_col)) == col_atom
end)
rescue
ArgumentError -> false
end
defp find_join_path(domain, target_table) do
target_name = to_string(target_table)
joins =
domain
|> Map.get(:source, %{})
|> Map.get(:joins, %{})
search_joins_recursive(joins, target_name, [])
end
defp search_joins_recursive(joins, target, path) when is_map(joins) do
Enum.find_value(joins, fn {join_name, join_config} ->
cond do
# Direct match
to_string(join_name) == target ->
Enum.reverse([join_name | path])
# Check nested joins
nested_joins = Map.get(join_config, :joins, %{}) ->
search_joins_recursive(nested_joins, target, [join_name | path])
true ->
nil
end
end)
end
defp search_joins_recursive(_, _, _), do: nil
defp apply_retarget(selecto, target_table, join_path) do
Logger.debug("Applying retarget: #{inspect(join_path)} -> #{target_table}")
# Use Selecto's built-in retarget functionality
Selecto.retarget(selecto, target_table, join: join_path)
end
end