Current section

Files

Jump to
selecto lib selecto field_resolver.ex
Raw

lib/selecto/field_resolver.ex

defmodule Selecto.FieldResolver do
@moduledoc """
Enhanced field resolution system for Selecto with disambiguation, aliasing, and dynamic resolution.
This module provides advanced field resolution capabilities including:
- Field disambiguation when multiple tables have same field names
- Dynamic field resolution at runtime
- Field aliasing for better naming and conflict resolution
- Smart error messages with suggestions
- Support for qualified field references
- Join-aware field validation
- Parameterized joins with dot notation support
## Field Reference Formats
### Basic Field References
- `"field_name"` - Field from source table
- `"join.field_name"` - Qualified field from specific join
- `"table_alias.field_name"` - Field using table alias
### Parameterized Join References (New)
- `"join:param.field_name"` - Parameterized join with single parameter
- `"join:param1:param2.field_name"` - Multiple parameters
- `"join:50:true:'quoted param'.field_name"` - Mixed parameter types
### Advanced Field References
- `{:field, "field_name", alias: "custom_alias"}` - Field with custom alias
- `{:qualified_field, "join.field_name"}` - Explicitly qualified field
- `{:disambiguated_field, "field_name", from: "join"}` - Disambiguated field reference
## Usage Examples
# Basic resolution
{:ok, field_info} = FieldResolver.resolve_field(selecto, "user_name")
# Qualified resolution
{:ok, field_info} = FieldResolver.resolve_field(selecto, "users.name")
# Parameterized join resolution
{:ok, field_info} = FieldResolver.resolve_field(selecto, "products:electronics:true.name")
# With disambiguation
{:ok, field_info} = FieldResolver.resolve_field(selecto, {:disambiguated_field, "name", from: "users"})
# Get all available fields
all_fields = FieldResolver.get_available_fields(selecto)
# Find field suggestions
suggestions = FieldResolver.suggest_fields(selecto, "nam")
"""
alias Selecto.Error
alias Selecto.FieldResolver.ParameterizedParser
@type field_reference :: String.t() | atom() | tuple()
@type field_info :: %{
name: String.t(),
qualified_name: String.t(),
source_join: atom(),
type: atom(),
alias: String.t() | nil,
table_alias: String.t() | nil,
parameters: [map()] | nil,
parameter_signature: String.t() | nil
}
@type resolution_result :: {:ok, field_info()} | {:error, term()}
@doc """
Resolve a field reference to detailed field information.
Handles various field reference formats and provides disambiguation when needed.
"""
@spec resolve_field(Selecto.t(), field_reference()) :: resolution_result()
def resolve_field(selecto, field_ref) do
case parse_field_reference(field_ref) do
{:ok, parsed_ref} ->
do_resolve_field(selecto, parsed_ref)
{:error, reason} ->
{:error,
Error.field_resolution_error("Invalid field reference format", field_ref, %{
reason: reason
})}
end
end
@doc """
Get all available fields from all joins and the source table.
Returns a map with field names as keys and field info as values.
"""
@spec get_available_fields(Selecto.t()) :: %{String.t() => field_info()}
def get_available_fields(selecto) do
source_fields = get_source_fields(selecto)
join_fields = get_join_fields(selecto)
cte_fields = get_cte_fields(selecto)
source_fields
# Prefer explicit join field definitions over permissive CTE fallback
# when both expose the same qualified name.
|> Map.merge(cte_fields)
|> Map.merge(join_fields)
end
@doc """
Find field suggestions for a partial field name.
Useful for autocomplete and error messages with suggestions.
"""
@spec suggest_fields(Selecto.t(), String.t()) :: [String.t()]
def suggest_fields(selecto, partial_name) do
available_fields = get_available_fields(selecto)
available_fields
|> Map.keys()
|> Enum.filter(&String.contains?(&1, partial_name))
|> Enum.sort_by(&String.jaro_distance(&1, partial_name), :desc)
|> Enum.take(5)
end
@doc """
Check if a field reference is ambiguous (exists in multiple tables).
"""
@spec is_ambiguous_field?(Selecto.t(), String.t()) :: boolean()
def is_ambiguous_field?(selecto, field_name) do
available_fields = get_available_fields(selecto)
qualified_fields =
available_fields
|> Enum.filter(fn {qualified_name, _info} ->
String.ends_with?(qualified_name, ".#{field_name}") or qualified_name == field_name
end)
length(qualified_fields) > 1
end
@doc """
Get disambiguation options for an ambiguous field.
"""
@spec get_disambiguation_options(Selecto.t(), String.t()) :: [field_info()]
def get_disambiguation_options(selecto, field_name) do
available_fields = get_available_fields(selecto)
available_fields
|> Enum.filter(fn {qualified_name, _info} ->
String.ends_with?(qualified_name, ".#{field_name}") or qualified_name == field_name
end)
|> Enum.map(fn {_qualified_name, info} -> info end)
end
@doc """
Validate that all field references in a list are resolvable.
"""
@spec validate_field_references(Selecto.t(), [field_reference()]) :: :ok | {:error, [term()]}
def validate_field_references(selecto, field_refs) do
errors =
Enum.reduce(field_refs, [], fn field_ref, acc ->
case resolve_field(selecto, field_ref) do
{:ok, _field_info} -> acc
{:error, error} -> [error | acc]
end
end)
case errors do
[] -> :ok
errors -> {:error, Enum.reverse(errors)}
end
end
# Private Implementation
# All parse_field_reference/1 clauses grouped together
defp parse_field_reference(field_ref) when is_binary(field_ref) do
ParameterizedParser.parse_field_reference(field_ref)
end
defp parse_field_reference(field_ref) when is_atom(field_ref) do
ParameterizedParser.parse_field_reference(Atom.to_string(field_ref))
end
defp parse_field_reference({:field, field_name, opts}) when is_list(opts) do
alias_name = Keyword.get(opts, :alias)
{:ok, %{type: :aliased, field: field_name, alias: alias_name, parameters: nil}}
end
defp parse_field_reference({:qualified_field, qualified_name}) do
ParameterizedParser.parse_field_reference(qualified_name)
|> case do
{:ok, parsed} -> {:ok, Map.put(parsed, :type, :explicitly_qualified)}
error -> error
end
end
defp parse_field_reference({:disambiguated_field, field_name, opts}) when is_list(opts) do
from_join = Keyword.get(opts, :from)
{:ok, %{type: :disambiguated, field: field_name, from_join: from_join, parameters: nil}}
end
defp parse_field_reference(field_ref) do
{:error, "Unsupported field reference format: #{inspect(field_ref)}"}
end
# All do_resolve_field/2 clauses grouped together
defp do_resolve_field(selecto, %{type: :qualified, join: join_name, field: field_name}) do
available_fields = get_available_fields(selecto)
qualified_name = "#{join_name}.#{field_name}"
case Map.get(available_fields, qualified_name) do
nil ->
case resolve_cte_field(selecto, join_name, field_name, qualified_name) do
{:ok, cte_field_info} ->
{:ok, cte_field_info}
:not_cte ->
# Check if the join exists
if Map.has_key?(selecto.config.joins, String.to_atom(join_name)) do
join_atom = String.to_atom(join_name)
join_info = selecto.config.joins[join_atom]
available_join_fields = Map.keys(join_info.fields || %{})
{:error,
Error.field_resolution_error(
"Field '#{field_name}' not found in join '#{join_name}'",
qualified_name,
%{available_fields_in_join: available_join_fields}
)}
else
available_joins = Map.keys(selecto.config.joins)
available_ctes = get_cte_names(selecto)
{:error,
Error.field_resolution_error(
"Join '#{join_name}' not found",
qualified_name,
%{available_joins: available_joins, available_ctes: available_ctes}
)}
end
{:error, error} ->
{:error, error}
end
field_info ->
{:ok, field_info}
end
end
defp do_resolve_field(selecto, %{type: :aliased, field: field_name, alias: alias_name}) do
case do_resolve_field(selecto, %{type: :simple, field: field_name}) do
{:ok, field_info} ->
{:ok, Map.put(field_info, :alias, alias_name)}
error ->
error
end
end
defp do_resolve_field(selecto, %{type: :disambiguated, field: field_name, from_join: from_join}) do
_qualified_name = "#{from_join}.#{field_name}"
do_resolve_field(selecto, %{type: :qualified, join: from_join, field: field_name})
end
defp do_resolve_field(selecto, %{type: :simple, field: field_name}) do
available_fields = get_available_fields(selecto)
# Try direct field name first
case Map.get(available_fields, field_name) do
nil ->
handle_field_not_found(selecto, field_name, available_fields)
field_info ->
{:ok, field_info}
end
end
defp do_resolve_field(selecto, %{type: :explicitly_qualified} = parsed_ref) do
# Remove the :explicitly_qualified type and treat as regular qualified
parsed_ref = Map.put(parsed_ref, :type, :qualified)
do_resolve_field(selecto, parsed_ref)
end
defp do_resolve_field(selecto, %{
type: :parameterized,
join: join_name,
field: field_name,
parameters: parameters
}) do
# Handle parameterized joins
join_atom = String.to_atom(join_name)
case Map.get(selecto.config.joins, join_atom) do
nil ->
available_joins = Map.keys(selecto.config.joins)
{:error,
Error.field_resolution_error(
"Parameterized join '#{join_name}' not found",
"#{join_name}:#{build_parameter_signature(parameters)}.#{field_name}",
%{available_joins: available_joins}
)}
join_config ->
# Validate parameters against join configuration
case validate_join_parameters(join_config, parameters) do
{:ok, validated_params} ->
# Build qualified name with parameter signature
parameter_sig = build_parameter_signature(parameters)
qualified_name = "#{join_name}:#{parameter_sig}.#{field_name}"
# Check if field exists in the join
case get_field_from_join(join_config, field_name) do
{:ok, field_type} ->
{:ok,
%{
name: field_name,
qualified_name: qualified_name,
source_join: join_atom,
type: field_type,
alias: nil,
table_alias: join_name,
parameters: validated_params,
parameter_signature: parameter_sig,
field: field_name
}}
{:error, reason} ->
{:error, Error.field_resolution_error(reason, qualified_name, %{})}
end
{:error, reason} ->
{:error,
Error.field_resolution_error(
"Parameter validation failed for join '#{join_name}': #{reason}",
"#{join_name}:#{build_parameter_signature(parameters)}.#{field_name}",
%{}
)}
end
end
end
# Helper function to handle field not found cases
defp handle_field_not_found(selecto, field_name, available_fields) do
# Check if it's an ambiguous field
if is_ambiguous_field?(selecto, field_name) do
options = get_disambiguation_options(selecto, field_name)
qualified_names = Enum.map(options, & &1.qualified_name)
{:error,
Error.field_resolution_error(
"Ambiguous field reference '#{field_name}'. Please qualify with table name.",
field_name,
%{available_options: qualified_names}
)}
else
suggestions = suggest_fields(selecto, field_name)
{:error,
Error.field_resolution_error(
"Field '#{field_name}' not found",
field_name,
%{suggestions: suggestions, available_fields: Map.keys(available_fields)}
)}
end
end
defp get_source_fields(selecto) do
source = selecto.config.source
# Handle case where source might be a string (table name) or a map/struct
fields =
case source do
source_map when is_map(source_map) ->
Map.get(source_map, :fields, [])
_ ->
# Fallback for when source is a string
[]
end
redact_fields =
case source do
source_map when is_map(source_map) ->
Map.get(source_map, :redact_fields, [])
_ ->
[]
end
source_columns =
case source do
source_map when is_map(source_map) ->
Map.get(source_map, :columns, %{})
_ ->
%{}
end
fields
|> Enum.filter(fn field -> field not in redact_fields end)
|> Enum.into(%{}, fn field ->
# Handle both atom and string fields for Postgrex compatibility
field_str = if is_atom(field), do: Atom.to_string(field), else: field
field_info = %{
name: field_str,
qualified_name: field_str,
source_join: :selecto_root,
type: get_field_type(source_columns, field),
alias: nil,
table_alias: "selecto_root",
field: field_str,
parameters: nil,
parameter_signature: nil
}
{field_str, field_info}
end)
end
defp get_join_fields(selecto) do
selecto.config.joins
|> Enum.flat_map(fn {join_name, join_config} ->
# For Postgrex domains, fields come from schemas, not directly from join config
join_fields =
if join_config[:fields] do
join_config.fields
else
# Look up schema by join source
schema_name =
case join_config[:source] do
source when is_atom(source) -> Atom.to_string(source)
source when is_binary(source) -> source
nil -> nil
end
if schema_name do
schema = get_in(selecto.domain, [:schemas, schema_name])
if schema && schema[:columns] do
# Convert schema columns to field format
Enum.into(schema.columns, %{}, fn {col_name, col_config} ->
{col_name, col_config}
end)
else
%{}
end
else
%{}
end
end
Enum.flat_map(join_fields, fn {field_key, field_config} ->
field_name = extract_field_name(field_key)
qualified_name = "#{join_name}.#{field_name}"
# Get the database field name from the configuration
database_field_name =
case Map.get(field_config, :field, field_config[:field]) do
atom when is_atom(atom) -> Atom.to_string(atom)
string when is_binary(string) -> string
nil -> field_name
end
field_info = %{
name: field_name,
qualified_name: qualified_name,
source_join: join_name,
type: Map.get(field_config, :type, field_config[:type]) || :string,
alias: Map.get(field_config, :alias, field_config[:alias]),
table_alias: Atom.to_string(join_name),
field: database_field_name,
parameters: nil,
parameter_signature: nil
}
[{qualified_name, field_info}]
end)
end)
|> Enum.into(%{})
end
defp get_cte_fields(selecto) do
selecto
|> get_cte_specs()
|> Enum.flat_map(fn cte_spec ->
cte_name = Map.get(cte_spec, :name)
columns = normalize_cte_columns(Map.get(cte_spec, :columns))
if is_binary(cte_name) and columns != [] do
Enum.map(columns, fn col ->
field_name = to_string(col)
qualified_name = "#{cte_name}.#{field_name}"
{qualified_name, build_cte_field_info(cte_name, field_name, :any)}
end)
else
[]
end
end)
|> Enum.into(%{})
end
defp resolve_cte_field(selecto, cte_name, field_name, qualified_name) do
case get_cte_spec_by_name(selecto, cte_name) do
nil ->
:not_cte
cte_spec ->
declared_columns = normalize_cte_columns(Map.get(cte_spec, :columns))
case declared_columns do
[] ->
# CTE exists but did not declare columns. Allow qualified references.
{:ok, build_cte_field_info(cte_name, field_name, :any)}
_ ->
if field_name in declared_columns do
{:ok, build_cte_field_info(cte_name, field_name, :any)}
else
{:error,
Error.field_resolution_error(
"Field '#{field_name}' not found in CTE '#{cte_name}'",
qualified_name,
%{available_fields_in_cte: declared_columns}
)}
end
end
end
end
defp build_cte_field_info(cte_name, field_name, field_type) do
%{
name: field_name,
qualified_name: "#{cte_name}.#{field_name}",
source_join: cte_name,
type: field_type,
alias: nil,
table_alias: cte_name,
field: field_name,
parameters: nil,
parameter_signature: nil
}
end
defp get_cte_specs(selecto) do
set = Map.get(selecto, :set, %{}) || %{}
set
|> Map.get(:ctes, [])
|> List.wrap()
end
defp get_cte_names(selecto) do
selecto
|> get_cte_specs()
|> Enum.map(&Map.get(&1, :name))
|> Enum.filter(&is_binary/1)
end
defp get_cte_spec_by_name(selecto, cte_name) do
Enum.find(get_cte_specs(selecto), fn spec -> Map.get(spec, :name) == cte_name end)
end
defp normalize_cte_columns(nil), do: []
defp normalize_cte_columns(columns) when is_list(columns), do: Enum.map(columns, &to_string/1)
defp normalize_cte_columns(_), do: []
defp extract_field_name(field_key) when is_binary(field_key) do
case String.split(field_key, ".", parts: 2) do
[_join, field_name] when field_name != "" -> field_name
_ -> field_key
end
end
defp extract_field_name(field_key) when is_atom(field_key) do
Atom.to_string(field_key)
end
defp extract_field_name(field_key) do
to_string(field_key)
end
defp get_field_type(columns, field) do
case Map.get(columns, field) do
%{type: type} -> type
_ -> :string
end
end
# Parameter validation and signature helpers
defp validate_join_parameters(join_config, provided_parameters) do
param_definitions = Map.get(join_config, :parameters, [])
case param_definitions do
[] when provided_parameters != [] ->
{:error, "Join does not accept parameters, but #{length(provided_parameters)} provided"}
[] ->
{:ok, []}
_ ->
ParameterizedParser.validate_parameters(provided_parameters, param_definitions)
end
end
defp build_parameter_signature(parameters) when is_list(parameters) and parameters != [] do
parameters
|> Enum.map(fn
{_type, value} when is_binary(value) -> value
{_type, value} -> to_string(value)
end)
|> Enum.join(":")
end
defp build_parameter_signature(_), do: ""
defp get_field_from_join(join_config, field_name) do
case Map.get(join_config, :fields) do
nil ->
{:error, "No fields defined for this join"}
fields ->
# Try to find the field in various formats
field_atom = String.to_atom(field_name)
field_key =
Enum.find([field_name, field_atom, "#{join_config.id}.#{field_name}"], fn key ->
Map.has_key?(fields, key)
end)
case field_key do
nil ->
available_fields = Map.keys(fields) |> Enum.map(&to_string/1)
{:error,
"Field '#{field_name}' not found in join. Available fields: #{Enum.join(available_fields, ", ")}"}
key ->
field_config = Map.get(fields, key)
field_type =
case field_config do
%{type: type} -> type
_ -> :string
end
{:ok, field_type}
end
end
end
end