defmodule Cinder.Filter.Helpers do @moduledoc """ Helper functions for building and validating custom filters. This module provides common patterns and utilities that custom filter developers can use to simplify their implementations. ## Usage defmodule MyApp.Filters.CustomFilter do use Cinder.Filter import Cinder.Filter.Helpers @impl true def process(raw_value, column) do with {:ok, trimmed} <- validate_string_input(raw_value), {:ok, parsed} <- parse_custom_value(trimmed) do build_filter(:my_filter, parsed, :equals) else _ -> nil end end end """ require Logger @doc """ Validates and trims string input, returning error for empty strings. ## Examples iex> validate_string_input(" hello ") {:ok, "hello"} iex> validate_string_input("") {:error, :empty} iex> validate_string_input(nil) {:error, :invalid} """ def validate_string_input(value) when is_binary(value) do trimmed = String.trim(value) if trimmed == "" do {:error, :empty} else {:ok, trimmed} end end def validate_string_input(_), do: {:error, :invalid} @doc """ Validates integer input with optional min/max bounds. ## Examples iex> validate_integer_input("42") {:ok, 42} iex> validate_integer_input("42", min: 0, max: 100) {:ok, 42} iex> validate_integer_input("150", max: 100) {:error, :out_of_bounds} iex> validate_integer_input("abc") {:error, :invalid} """ def validate_integer_input(input, opts \\ []) def validate_integer_input(input, opts) when is_binary(input) do case Integer.parse(input) do {int_value, ""} -> min_value = Keyword.get(opts, :min) max_value = Keyword.get(opts, :max) cond do min_value && int_value < min_value -> {:error, :out_of_bounds} max_value && int_value > max_value -> {:error, :out_of_bounds} true -> {:ok, int_value} end _ -> {:error, :invalid} end end def validate_integer_input(_, _), do: {:error, :invalid} @doc """ Validates float input with optional min/max bounds. ## Examples iex> validate_float_input("42.5") {:ok, 42.5} iex> validate_float_input("42.5", min: 0.0, max: 100.0) {:ok, 42.5} iex> validate_float_input("150.0", max: 100.0) {:error, :out_of_bounds} iex> validate_float_input("abc") {:error, :invalid} """ def validate_float_input(input, opts \\ []) def validate_float_input(input, opts) when is_binary(input) do case Float.parse(input) do {float_value, ""} -> min_value = Keyword.get(opts, :min) max_value = Keyword.get(opts, :max) cond do min_value && float_value < min_value -> {:error, :out_of_bounds} max_value && float_value > max_value -> {:error, :out_of_bounds} true -> {:ok, float_value} end _ -> {:error, :invalid} end end def validate_float_input(_, _), do: {:error, :invalid} @doc """ Validates date input in ISO 8601 format. ## Examples iex> validate_date_input("2023-12-25") {:ok, ~D[2023-12-25]} iex> validate_date_input("invalid-date") {:error, :invalid} """ def validate_date_input(value) when is_binary(value) do case Date.from_iso8601(value) do {:ok, date} -> {:ok, date} _ -> {:error, :invalid} end end def validate_date_input(_), do: {:error, :invalid} @doc """ Validates hex color input. ## Examples iex> validate_hex_color_input("#FF0000") {:ok, "#ff0000"} iex> validate_hex_color_input("#fff") {:error, :invalid} iex> validate_hex_color_input("red") {:error, :invalid} """ def validate_hex_color_input(value) when is_binary(value) do trimmed = String.trim(value) if Regex.match?(~r/^#[0-9A-Fa-f]{6}$/, trimmed) do {:ok, String.downcase(trimmed)} else {:error, :invalid} end end def validate_hex_color_input(_), do: {:error, :invalid} @doc """ Validates and parses comma-separated values. ## Examples iex> validate_csv_input("a,b,c") {:ok, ["a", "b", "c"]} iex> validate_csv_input("a, b , c ", trim: true) {:ok, ["a", "b", "c"]} iex> validate_csv_input("", min_length: 1) {:error, :empty} """ def validate_csv_input(input, opts \\ []) def validate_csv_input(input, opts) when is_binary(input) do separator = Keyword.get(opts, :separator, ",") trim_values = Keyword.get(opts, :trim, true) min_length = Keyword.get(opts, :min_length, 0) max_length = Keyword.get(opts, :max_length, 1000) values = input |> String.split(separator) |> Enum.map(fn val -> if trim_values, do: String.trim(val), else: val end) |> Enum.reject(&(&1 == "")) cond do length(values) < min_length -> {:error, :empty} length(values) > max_length -> {:error, :too_many} true -> {:ok, values} end end def validate_csv_input(_, _), do: {:error, :invalid} @doc """ Builds a standard filter map. ## Examples iex> build_filter(:my_filter, "value", :equals) %{type: :my_filter, value: "value", operator: :equals} iex> build_filter(:slider, 50, :less_than_or_equal, case_sensitive: false) %{type: :slider, value: 50, operator: :less_than_or_equal, case_sensitive: false} """ def build_filter(type, value, operator, extra_fields \\ []) do base_filter = %{ type: type, value: value, operator: operator } Enum.reduce(extra_fields, base_filter, fn {key, val}, acc -> Map.put(acc, key, val) end) end @doc """ Validates a filter structure has required fields. ## Examples iex> validate_filter_structure(%{type: :text, value: "test", operator: :equals}) {:ok, %{type: :text, value: "test", operator: :equals}} iex> validate_filter_structure(%{type: :text, value: "test"}) {:error, :missing_operator} """ def validate_filter_structure(filter) when is_map(filter) do required_fields = [:type, :value, :operator] missing_fields = required_fields |> Enum.reject(&Map.has_key?(filter, &1)) if Enum.empty?(missing_fields) do {:ok, filter} else {:error, {:missing_fields, missing_fields}} end end def validate_filter_structure(_), do: {:error, :invalid_structure} @doc """ Validates operator is in allowed list. ## Examples iex> validate_operator(:equals, [:equals, :contains]) {:ok, :equals} iex> validate_operator(:invalid, [:equals, :contains]) {:error, :invalid_operator} """ def validate_operator(operator, allowed_operators) when is_atom(operator) do if operator in allowed_operators do {:ok, operator} else {:error, :invalid_operator} end end def validate_operator(_, _), do: {:error, :invalid_operator} @doc """ Builds a relationship-aware Ash query filter with embedded field support. Handles direct fields, relationship fields using dot notation, and embedded fields using double-underscore notation. ## Examples build_ash_filter(query, "name", "John", :equals) build_ash_filter(query, "user.name", "John", :equals) build_ash_filter(query, "profile__first_name", "John", :equals) build_ash_filter(query, "settings__address__street", "Main St", :contains) """ def build_ash_filter(query, field, value, operator, opts \\ []) when is_binary(field) do require Ash.Query import Ash.Expr case parse_field_notation(field) do {:direct, field_name} -> field_atom = String.to_atom(field_name) apply_operator_to_field(query, field_atom, value, operator, opts) {:relationship, rel_path, field_name} -> rel_path_atoms = Enum.map(rel_path, &String.to_atom/1) field_atom = String.to_atom(field_name) apply_operator_to_relationship(query, rel_path_atoms, field_atom, value, operator, opts) {:embedded, embed_field, field_name} -> embed_atom = String.to_atom(embed_field) field_atom = String.to_atom(field_name) apply_operator_to_embedded(query, embed_atom, field_atom, value, operator, opts) {:nested_embedded, embed_field, field_path} -> embed_atom = String.to_atom(embed_field) apply_operator_to_nested_embedded(query, embed_atom, field_path, value, operator, opts) {:relationship_embedded, rel_path, embed_field, field_name} -> rel_path_atoms = Enum.map(rel_path, &String.to_atom/1) embed_atom = String.to_atom(embed_field) field_atom = String.to_atom(field_name) apply_operator_to_relationship_embedded( query, rel_path_atoms, embed_atom, field_atom, value, operator, opts ) {:relationship_nested_embedded, rel_path, embed_field, field_path} -> rel_path_atoms = Enum.map(rel_path, &String.to_atom/1) embed_atom = String.to_atom(embed_field) apply_operator_to_relationship_nested_embedded( query, rel_path_atoms, embed_atom, field_path, value, operator, opts ) {:invalid, _} -> # Return query unchanged for invalid field notation query end end defp apply_operator_to_relationship(query, rel_path, field_atom, value, operator, opts) do require Ash.Query import Ash.Expr case operator do :equals -> Ash.Query.filter(query, exists(^rel_path, ^ref(field_atom) == ^value)) :contains -> Ash.Query.filter( query, exists(^rel_path, contains(type(^ref(field_atom), :string), ^value)) ) :starts_with -> Ash.Query.filter( query, exists(^rel_path, contains(type(^ref(field_atom), :string), ^value)) ) :ends_with -> Ash.Query.filter( query, exists(^rel_path, contains(type(^ref(field_atom), :string), ^value)) ) :greater_than -> Ash.Query.filter(query, exists(^rel_path, ^ref(field_atom) > ^value)) :greater_than_or_equal -> Ash.Query.filter(query, exists(^rel_path, ^ref(field_atom) >= ^value)) :less_than -> Ash.Query.filter(query, exists(^rel_path, ^ref(field_atom) < ^value)) :less_than_or_equal -> Ash.Query.filter(query, exists(^rel_path, ^ref(field_atom) <= ^value)) :in when is_list(value) -> match_mode = Keyword.get(opts, :match_mode, :any) case value do [] -> query [single_value] -> # Single value: same for both modes Ash.Query.filter(query, exists(^rel_path, ^ref(field_atom) == ^single_value)) multiple_values -> case match_mode do :all -> # ALL mode: must have a related record matching each value # exists(rel, field == val1) AND exists(rel, field == val2) AND ... conditions = Enum.map(multiple_values, fn val -> expr(exists(^rel_path, ^ref(field_atom) == ^val)) end) combined = Enum.reduce(conditions, fn condition, acc -> expr(^acc and ^condition) end) Ash.Query.filter(query, ^combined) _ -> # ANY mode (default): exists with IN operator Ash.Query.filter(query, exists(^rel_path, ^ref(field_atom) in ^multiple_values)) end end _ -> query end end defp apply_operator_to_field(query, field_atom, value, operator, opts) do require Ash.Query import Ash.Expr case operator do :equals -> # Detect array fields and use containment logic resource = query.resource case get_field_type(resource, field_atom) do {:array, _element_type} -> # For array fields, use containment: value in array_field # This handles the case where we want to find records where # the array contains the selected value case value do "" -> # Empty string - return unchanged query query nil -> # Nil value - return unchanged query query _ -> # Non-empty value: create containment filter Ash.Query.filter(query, ^value in ^ref(field_atom)) end _other_type -> # Non-array fields use standard equality case value do "" -> # Empty string - return unchanged query for consistency query nil -> # Nil value - return unchanged query query _ -> # Non-empty value: use standard equality Ash.Query.filter(query, ^ref(field_atom) == ^value) end end :contains -> Ash.Query.filter(query, contains(type(^ref(field_atom), :string), ^value)) :starts_with -> Ash.Query.filter(query, contains(type(^ref(field_atom), :string), ^value)) :ends_with -> Ash.Query.filter(query, contains(type(^ref(field_atom), :string), ^value)) :greater_than -> if is_nil(value) do query else Ash.Query.filter(query, ^ref(field_atom) > ^value) end :greater_than_or_equal -> if is_nil(value) do query else Ash.Query.filter(query, ^ref(field_atom) >= ^value) end :less_than -> if is_nil(value) do query else Ash.Query.filter(query, ^ref(field_atom) < ^value) end :less_than_or_equal -> if is_nil(value) do query else Ash.Query.filter(query, ^ref(field_atom) <= ^value) end :in when is_list(value) -> # Detect if this is an array field and handle accordingly resource = query.resource case get_field_type(resource, field_atom) do {:array, _element_type} -> # For array fields, we want: value in array_field (not array_field in [values]) # Handle multiple values with AND or OR logic based on match_mode match_mode = Keyword.get(opts, :match_mode, :any) case value do [] -> # Empty array - return unchanged query query [single_value] -> # Single value: value in field (same for both AND/OR modes) Ash.Query.filter(query, ^single_value in ^ref(field_atom)) multiple_values -> # Multiple values: combine based on match_mode conditions = Enum.map(multiple_values, fn val -> expr(^val in ^ref(field_atom)) end) # Combine with AND or OR based on match_mode combined_condition = case match_mode do :all -> # ALL mode: (val1 in field) AND (val2 in field) AND ... Enum.reduce(conditions, fn condition, acc -> expr(^acc and ^condition) end) _ -> # ANY mode (default): (val1 in field) OR (val2 in field) OR ... Enum.reduce(conditions, fn condition, acc -> expr(^acc or ^condition) end) end Ash.Query.filter(query, ^combined_condition) end _other_type -> # Non-array fields use standard logic: field in [values] case value do [] -> # Empty array - return unchanged query query _ -> # Non-empty values: use standard IN operator Ash.Query.filter(query, ^ref(field_atom) in ^value) end end _ -> query end end # Helper function to get field type information defp get_field_type(resource, field_atom) do case Ash.Resource.Info.attribute(resource, field_atom) do %{type: field_type} -> field_type nil -> :unknown end end @doc """ Common empty value check for most filter types. ## Examples iex> empty_value?(nil) true iex> empty_value?("") true iex> empty_value?([]) true iex> empty_value?(%{value: nil}) true iex> empty_value?("test") false """ def empty_value?(value) do case value do nil -> true "" -> true [] -> true %{value: nil} -> true %{value: ""} -> true %{value: []} -> true _ -> false end end @doc """ Extracts filter options with type safety. ## Examples iex> extract_option([min: 0, max: 100], :min, 50) 0 iex> extract_option([], :min, 50) 50 iex> extract_option(%{min: 0, max: 100}, :min, 50) 0 """ def extract_option(options, key, default) when is_list(options) do Keyword.get(options, key, default) end def extract_option(options, key, default) when is_map(options) do Map.get(options, key, default) end def extract_option(_, _, default), do: default @doc """ Debug helper for filter development. Logs filter processing information when debug is enabled. ## Examples debug_filter("MyFilter", "processing input", %{input: "test"}) """ def debug_filter(filter_name, message, data \\ %{}) do if Application.get_env(:cinder, :debug_filters, false) do require Logger Logger.debug(""" [Cinder.Filter.Debug] #{filter_name}: #{message} Data: #{inspect(data, pretty: true)} """) end end @doc """ Validates that a module properly implements the Cinder.Filter behaviour. ## Examples iex> validate_filter_implementation(MyApp.Filters.ValidFilter) {:ok, "Filter implementation is valid"} iex> validate_filter_implementation(InvalidModule) {:error, ["Missing callback: render/4", "Missing callback: process/2"]} """ def validate_filter_implementation(module) when is_atom(module) do required_callbacks = [ {:render, 4}, {:process, 2}, {:validate, 1}, {:default_options, 0}, {:empty?, 1}, {:build_query, 3} ] missing_callbacks = required_callbacks |> Enum.reject(fn {function, arity} -> function_exported?(module, function, arity) end) |> Enum.map(fn {function, arity} -> "Missing callback: #{function}/#{arity}" end) if Enum.empty?(missing_callbacks) do {:ok, "Filter implementation is valid"} else {:error, missing_callbacks} end end def validate_filter_implementation(_), do: {:error, ["Invalid module"]} @doc """ Parses field notation to determine field type and structure. Embedded fields are written with double-underscore notation (`profile__first_name`). ## Examples iex> parse_field_notation("username") {:direct, "username"} iex> parse_field_notation("user.name") {:relationship, ["user"], "name"} iex> parse_field_notation("profile__first_name") {:embedded, "profile", "first_name"} iex> parse_field_notation("settings__address__street") {:nested_embedded, "settings", ["address", "street"]} iex> parse_field_notation("user.profile__first_name") {:relationship_embedded, ["user"], "profile", "first_name"} """ def parse_field_notation(field) when is_binary(field) do trimmed = String.trim(field) maybe_warn_bracket_notation(trimmed) # Embedded fields use double-underscore notation; normalise to the internal bracket # form so the parsers below handle both. (Bracket input is idempotent here.) normalized = embedded_field_from_url_safe(trimmed) cond do normalized == "" -> {:invalid, field} # Check for invalid bracket notation (missing colon) String.contains?(normalized, "[") and not String.contains?(normalized, "[:") -> {:invalid, field} # Check for whitespace in field names String.contains?(normalized, " ") -> {:invalid, field} # Check for unclosed brackets String.contains?(normalized, "[:") and not String.ends_with?(normalized, "]") -> {:invalid, field} String.contains?(normalized, "[:") -> parse_embedded_field_notation(normalized) String.contains?(normalized, ".") -> parse_relationship_field_notation(normalized) true -> {:direct, normalized} end end # Bracket notation (`profile[:first_name]`) is deprecated in favour of double-underscore # notation (`profile__first_name`). Warn once per distinct field so we don't flood logs # when the same column is parsed on every render/filter/sort. defp maybe_warn_bracket_notation(field) do if String.contains?(field, "[:") do key = {__MODULE__, :deprecated_bracket_notation, field} unless :persistent_term.get(key, false) do :persistent_term.put(key, true) Logger.warning( "Embedded field #{inspect(field)} uses deprecated bracket notation. " <> "Use double-underscore notation instead (e.g. \"profile__first_name\"). " <> "Bracket notation will be removed in Cinder 1.0." ) end end end # Parses embedded field notation like "profile[:first_name]" or "settings[:address][:street]" defp parse_embedded_field_notation(field) do # Check for mixed relationship + embedded: "user.profile[:first_name]" if String.contains?(field, ".") and String.contains?(field, "[:") do parse_mixed_relationship_embedded(field) else # Pure embedded field notation parse_pure_embedded_field(field) end end # Parses mixed relationship and embedded notation like "user.profile[:first_name]" defp parse_mixed_relationship_embedded(field) do case String.split(field, "[:") do [relationship_part | embed_parts] -> # Split relationship part by dots rel_parts = String.split(relationship_part, ".") case rel_parts do rel_path when length(rel_path) >= 2 -> # Extract the embedded field name (last part before [:) {rel_path_init, [embed_field]} = Enum.split(rel_parts, -1) case parse_embedded_field_from_parts(embed_parts) do {field_name} when is_binary(field_name) -> {:relationship_embedded, rel_path_init, embed_field, field_name} {field_path} when is_list(field_path) -> {:relationship_nested_embedded, rel_path_init, embed_field, field_path} :invalid -> {:invalid, field} end _ -> {:invalid, field} end _ -> {:invalid, field} end end # Parses pure embedded field notation like "profile[:first_name]" or "settings[:address][:street]" defp parse_pure_embedded_field(field) do case String.split(field, "[:") do [embed_field | field_parts] -> case parse_embedded_field_from_parts(field_parts) do {field_name} when is_binary(field_name) -> {:embedded, embed_field, field_name} {field_path} when is_list(field_path) -> {:nested_embedded, embed_field, field_path} :invalid -> {:invalid, field} end _ -> {:invalid, field} end end # Helper to parse the embedded field parts from bracket notation defp parse_embedded_field_from_parts(parts) do field_names = parts |> Enum.map(&String.replace(&1, "]", "")) |> Enum.reject(&(&1 == "")) |> Enum.map(&String.trim/1) # Validate all field names if Enum.all?(field_names, &valid_field_name?/1) do case field_names do [single_field] -> {single_field} multiple_fields when length(multiple_fields) > 1 -> {multiple_fields} _ -> :invalid end else :invalid end end # Parses relationship field notation like "user.name" or "user.company.name" defp parse_relationship_field_notation(field) do parts = String.split(field, ".") case parts do # No dots after split means it's direct [_single] -> {:direct, field} parts when length(parts) > 1 -> {rel_path, [field_name]} = Enum.split(parts, -1) {:relationship, rel_path, field_name} _ -> {:invalid, field} end end # Validates field names according to Elixir atom naming rules defp valid_field_name?(name) do String.match?(name, ~r/^[a-z][a-zA-Z0-9_]*$/) end # Apply operators to embedded fields defp apply_operator_to_embedded(query, embed_atom, field_atom, value, operator, _opts) do require Ash.Query import Ash.Expr case operator do :equals -> Ash.Query.filter(query, get_path(^ref(embed_atom), [^field_atom]) == ^value) :contains -> Ash.Query.filter(query, contains(get_path(^ref(embed_atom), [^field_atom]), ^value)) :starts_with -> Ash.Query.filter(query, contains(get_path(^ref(embed_atom), [^field_atom]), ^value)) :ends_with -> Ash.Query.filter(query, contains(get_path(^ref(embed_atom), [^field_atom]), ^value)) :greater_than -> Ash.Query.filter(query, get_path(^ref(embed_atom), [^field_atom]) > ^value) :greater_than_or_equal -> Ash.Query.filter(query, get_path(^ref(embed_atom), [^field_atom]) >= ^value) :less_than -> Ash.Query.filter(query, get_path(^ref(embed_atom), [^field_atom]) < ^value) :less_than_or_equal -> Ash.Query.filter(query, get_path(^ref(embed_atom), [^field_atom]) <= ^value) :in when is_list(value) -> Ash.Query.filter(query, get_path(^ref(embed_atom), [^field_atom]) in ^value) _ -> query end end # Apply operators to nested embedded fields defp apply_operator_to_nested_embedded( query, embed_atom, field_path, value, operator, _opts ) do require Ash.Query import Ash.Expr # Convert field path to atoms for get_path field_atoms = Enum.map(field_path, &String.to_atom/1) case operator do :equals -> Ash.Query.filter(query, get_path(^ref(embed_atom), ^field_atoms) == ^value) :contains -> Ash.Query.filter(query, contains(get_path(^ref(embed_atom), ^field_atoms), ^value)) :starts_with -> Ash.Query.filter(query, contains(get_path(^ref(embed_atom), ^field_atoms), ^value)) :ends_with -> Ash.Query.filter(query, contains(get_path(^ref(embed_atom), ^field_atoms), ^value)) :greater_than -> Ash.Query.filter(query, get_path(^ref(embed_atom), ^field_atoms) > ^value) :greater_than_or_equal -> Ash.Query.filter(query, get_path(^ref(embed_atom), ^field_atoms) >= ^value) :less_than -> Ash.Query.filter(query, get_path(^ref(embed_atom), ^field_atoms) < ^value) :less_than_or_equal -> Ash.Query.filter(query, get_path(^ref(embed_atom), ^field_atoms) <= ^value) :in when is_list(value) -> Ash.Query.filter(query, get_path(^ref(embed_atom), ^field_atoms) in ^value) _ -> query end end # Apply operators to relationship + embedded fields defp apply_operator_to_relationship_embedded( query, rel_path, embed_atom, field_atom, value, operator, _opts ) do require Ash.Query import Ash.Expr case operator do :equals -> Ash.Query.filter( query, exists(^rel_path, get_path(^ref(embed_atom), [^field_atom]) == ^value) ) :contains -> Ash.Query.filter( query, exists(^rel_path, contains(get_path(^ref(embed_atom), [^field_atom]), ^value)) ) :starts_with -> Ash.Query.filter( query, exists(^rel_path, contains(get_path(^ref(embed_atom), [^field_atom]), ^value)) ) :ends_with -> Ash.Query.filter( query, exists(^rel_path, contains(get_path(^ref(embed_atom), [^field_atom]), ^value)) ) :greater_than -> Ash.Query.filter( query, exists(^rel_path, get_path(^ref(embed_atom), [^field_atom]) > ^value) ) :greater_than_or_equal -> Ash.Query.filter( query, exists(^rel_path, get_path(^ref(embed_atom), [^field_atom]) >= ^value) ) :less_than -> Ash.Query.filter( query, exists(^rel_path, get_path(^ref(embed_atom), [^field_atom]) < ^value) ) :less_than_or_equal -> Ash.Query.filter( query, exists(^rel_path, get_path(^ref(embed_atom), [^field_atom]) <= ^value) ) :in when is_list(value) -> Ash.Query.filter( query, exists(^rel_path, get_path(^ref(embed_atom), [^field_atom]) in ^value) ) _ -> query end end # Apply operators to relationship + nested embedded fields defp apply_operator_to_relationship_nested_embedded( query, rel_path, embed_atom, field_path, value, operator, _opts ) do require Ash.Query import Ash.Expr # Convert field path to atoms for get_path field_atoms = Enum.map(field_path, &String.to_atom/1) case operator do :equals -> Ash.Query.filter( query, exists(^rel_path, get_path(^ref(embed_atom), ^field_atoms) == ^value) ) :contains -> Ash.Query.filter( query, exists(^rel_path, contains(get_path(^ref(embed_atom), ^field_atoms), ^value)) ) :starts_with -> Ash.Query.filter( query, exists(^rel_path, contains(get_path(^ref(embed_atom), ^field_atoms), ^value)) ) :ends_with -> Ash.Query.filter( query, exists(^rel_path, contains(get_path(^ref(embed_atom), ^field_atoms), ^value)) ) :greater_than -> Ash.Query.filter( query, exists(^rel_path, get_path(^ref(embed_atom), ^field_atoms) > ^value) ) :greater_than_or_equal -> Ash.Query.filter( query, exists(^rel_path, get_path(^ref(embed_atom), ^field_atoms) >= ^value) ) :less_than -> Ash.Query.filter( query, exists(^rel_path, get_path(^ref(embed_atom), ^field_atoms) < ^value) ) :less_than_or_equal -> Ash.Query.filter( query, exists(^rel_path, get_path(^ref(embed_atom), ^field_atoms) <= ^value) ) :in when is_list(value) -> Ash.Query.filter( query, exists(^rel_path, get_path(^ref(embed_atom), ^field_atoms) in ^value) ) _ -> query end end # Converts double-underscore embedded notation to the internal bracket form # ("profile__first_name" -> "profile[:first_name]"), leaving relationship dots and direct # fields unchanged. Used only by parse_field_notation/1 to normalise its input. defp embedded_field_from_url_safe(field) when is_binary(field) do # Handle mixed relationship.embedded__field patterns if String.contains?(field, "__") do # Split on dots first to handle relationship.embedded__field patterns parts = String.split(field, ".") converted_parts = Enum.map(parts, fn part -> if String.contains?(part, "__") do # This part contains embedded field notation [base | field_parts] = String.split(part, "__") embedded_parts = Enum.map(field_parts, fn fp -> "[:#{fp}]" end) base <> Enum.join(embedded_parts) else part end end) Enum.join(converted_parts, ".") else field end end @doc """ Converts field notation to human-readable labels. ## Examples iex> humanize_embedded_field("profile__first_name") "Profile > First Name" iex> humanize_embedded_field("user.profile__first_name") "User > Profile > First Name" """ def humanize_embedded_field(field) when is_binary(field) do case parse_field_notation(field) do {:direct, field_name} -> Cinder.Filter.humanize_key(field_name) {:relationship, rel_path, field_name} -> rel_labels = Enum.map(rel_path, &Cinder.Filter.humanize_key/1) field_label = Cinder.Filter.humanize_key(field_name) Enum.join(rel_labels ++ [field_label], " > ") {:embedded, embed_field, field_name} -> embed_label = Cinder.Filter.humanize_key(embed_field) field_label = Cinder.Filter.humanize_key(field_name) "#{embed_label} > #{field_label}" {:nested_embedded, embed_field, field_path} -> embed_label = Cinder.Filter.humanize_key(embed_field) field_labels = Enum.map(field_path, &Cinder.Filter.humanize_key/1) Enum.join([embed_label | field_labels], " > ") {:relationship_embedded, rel_path, embed_field, field_name} -> rel_labels = Enum.map(rel_path, &Cinder.Filter.humanize_key/1) embed_label = Cinder.Filter.humanize_key(embed_field) field_label = Cinder.Filter.humanize_key(field_name) Enum.join(rel_labels ++ [embed_label, field_label], " > ") {:relationship_nested_embedded, rel_path, embed_field, field_path} -> rel_labels = Enum.map(rel_path, &Cinder.Filter.humanize_key/1) embed_label = Cinder.Filter.humanize_key(embed_field) field_labels = Enum.map(field_path, &Cinder.Filter.humanize_key/1) Enum.join(rel_labels ++ [embed_label | field_labels], " > ") {:invalid, _} -> Cinder.Filter.humanize_key(field) end end @doc """ Validates embedded field syntax and returns appropriate error messages. ## Examples iex> validate_embedded_field_syntax("profile[:first_name]") :ok iex> validate_embedded_field_syntax("profile[invalid]") {:error, "Invalid embedded field syntax: missing colon"} """ def validate_embedded_field_syntax(field) when is_binary(field) do case parse_field_notation(field) do {:invalid, _} -> cond do String.contains?(field, "[") and not String.contains?(field, "[:") -> {:error, "Invalid embedded field syntax: missing colon"} String.contains?(field, "[:") and not String.contains?(field, "]") -> {:error, "Invalid embedded field syntax: unclosed bracket"} String.contains?(field, "[:]") -> {:error, "Invalid embedded field syntax: empty field name"} String.contains?(field, "[:") and String.contains?(field, "-") -> {:error, "Invalid embedded field syntax: invalid field name characters"} true -> {:error, "Invalid field syntax"} end _ -> :ok end end end