defmodule AshDispatch.VariableInterpolator do @moduledoc """ Interpolates variables in template strings. Replaces `{{variable}}` placeholders with actual values from event data. ## Examples Basic interpolation: iex> interpolate("Order #\{{id}}", %{order: %{id: 123}}, :order) "Order #123" Nested attributes (preloaded relationships): iex> interpolate("Hello \{{user.name}}", %{order: %{user: %{name: "Alice"}}}, :order) "Hello Alice" Flattened keys (user.name becomes user_name): iex> interpolate("Hello \{{user_name}}", %{order: %{user: %{name: "Alice"}}}, :order) "Hello Alice" Missing values become empty string: iex> interpolate("Value: \{{missing}}", %{order: %{}}, :order) "Value: " ## Variable Syntax Variables use double curly braces: `\{{variable_name}}` **Direct attributes:** - `{{id}}` → resource.id - `{{status}}` → resource.status - `{{total}}` → resource.total **Nested attributes (dot notation):** - `{{user.name}}` → resource.user.name - `{{user.email}}` → resource.user.email - `{{organization.name}}` → resource.organization.name **Flattened keys (underscore notation):** - `{{user_name}}` → resource.user.name - `{{user_email}}` → resource.user.email - `{{organization_name}}` → resource.organization.name ## Resource Key The `resource_key` parameter specifies which key in the data map contains the main resource record: data = %{order: %Order{id: 123}, user: %User{name: "Alice"}} interpolate("Order {{id}}", data, :order) # Uses data.order for variable lookup ## Preloading For nested attributes to work, relationships must be preloaded: dispatch do event :created, load: [:user, :organization], # Preload relationships content: [ notification_message: "{{user_name}} created order in {{organization_name}}" ] end ## Type Conversion Values are converted to strings: - Atoms: `:active` → "active" - Numbers: `123` → "123" - Booleans: `true` → "true" - DateTime: `~U[2025-01-01 10:00:00Z]` → "2025-01-01 10:00:00Z" - Nil: `nil` → "" (empty string) ## Safety - Missing variables become empty strings (no errors) - Nil values become empty strings - Handles missing relationships gracefully - Does not execute code (just string replacement) """ @doc """ Interpolates variables in a template string. ## Parameters - `template` - String template with `{{variable}}` placeholders - `data` - Map containing event data (resource records) - `resource_key` - Atom key for the main resource in data map ## Returns String with variables replaced by actual values ## Examples iex> interpolate("Order #\{{id}}", %{order: %{id: 123}}, :order) "Order #123" iex> interpolate("\{{user.name}}", %{ticket: %{user: %{name: "Alice"}}}, :ticket) "Alice" iex> interpolate("\{{missing}}", %{order: %{}}, :order) "" """ @spec interpolate(String.t(), map(), atom()) :: String.t() def interpolate(template, data, resource_key) when is_binary(template) do # Get the main resource record resource = Map.get(data, resource_key) || Map.get(data, to_string(resource_key)) # Find all variables in template Regex.replace(~r/\{\{([^}]+)\}\}/, template, fn _, var_name -> var_name = String.trim(var_name) resolve_variable(var_name, resource) |> to_string_safe() end) end def interpolate(nil, _data, _resource_key), do: "" def interpolate(template, _data, _resource_key) when not is_binary(template), do: "" # Private functions # Resolve a variable from the resource defp resolve_variable(var_name, resource) when is_map(resource) do cond do # Try dot notation first: user.name String.contains?(var_name, ".") -> resolve_nested_path(String.split(var_name, "."), resource) # Try underscore as flattened dot notation: user_name → user.name String.contains?(var_name, "_") -> parts = String.split(var_name, "_", parts: 2) case parts do [key, rest] -> # Try as nested first, but catch atom errors nested_value = try do key_atom = String.to_existing_atom(key) Map.get(resource, key_atom) rescue ArgumentError -> nil end case nested_value do nil -> # Fall back to direct attribute try do Map.get(resource, String.to_existing_atom(var_name)) rescue ArgumentError -> nil end nested when is_map(nested) -> # Recursively resolve rest resolve_variable(rest, nested) _ -> # Not a map, try direct attribute try do Map.get(resource, String.to_existing_atom(var_name)) rescue ArgumentError -> nil end end _ -> nil end # Direct attribute: id, status, etc. true -> try do Map.get(resource, String.to_existing_atom(var_name)) rescue ArgumentError -> nil end end end defp resolve_variable(_var_name, _resource), do: nil # Resolve nested path like ["user", "name"] defp resolve_nested_path([], value), do: value defp resolve_nested_path([key | rest], resource) when is_map(resource) do case Map.get(resource, String.to_existing_atom(key)) do nil -> nil value -> resolve_nested_path(rest, value) end rescue ArgumentError -> nil end defp resolve_nested_path(_path, _resource), do: nil # Convert value to string safely defp to_string_safe(nil), do: "" defp to_string_safe(value) when is_binary(value), do: value defp to_string_safe(value) when is_atom(value), do: Atom.to_string(value) defp to_string_safe(value) when is_integer(value), do: Integer.to_string(value) defp to_string_safe(value) when is_float(value), do: Float.to_string(value) defp to_string_safe(true), do: "true" defp to_string_safe(false), do: "false" defp to_string_safe(%DateTime{} = dt) do DateTime.to_string(dt) end defp to_string_safe(%Date{} = date) do Date.to_string(date) end defp to_string_safe(%Time{} = time) do Time.to_string(time) end defp to_string_safe(%_{} = struct) do # For other structs, try to convert to string to_string(struct) rescue _ -> "" end defp to_string_safe(value) when is_map(value) do # Don't convert maps to strings (would be useless) "" end defp to_string_safe(value) when is_list(value) do # Don't convert lists to strings "" end defp to_string_safe(_value), do: "" end