defmodule SelectoComponents.Views.Aggregate.Process do alias SelectoComponents.Helpers.BucketParser alias SelectoComponents.Param alias SelectoComponents.SchemaUtils alias SelectoComponents.SafeAtom alias SelectoComponents.SqlSafety alias SelectoComponents.Views.Aggregate.Options def param_to_state(params, _v) do %{ group_by: SelectoComponents.Views.view_param_process(params, "group_by", "field"), aggregate: SelectoComponents.Views.view_param_process(params, "aggregate", "field"), per_page: Options.normalize_per_page_param(Map.get(params, "aggregate_per_page")), grid: truthy_param?(Map.get(params, "aggregate_grid")), grid_colorize: truthy_param?(Map.get(params, "aggregate_grid_colorize")), grid_color_scale: Options.normalize_grid_color_scale_mode(Map.get(params, "aggregate_grid_color_scale")) } end def initial_state(selecto, _v) do %{ aggregate: Map.get(Selecto.domain(selecto), :default_aggregate, []) |> SelectoComponents.Helpers.build_initial_state(), group_by: Map.get(Selecto.domain(selecto), :default_group_by, []) |> SelectoComponents.Helpers.build_initial_state(), per_page: Options.default_per_page(), grid: false, grid_colorize: false, grid_color_scale: Options.default_grid_color_scale_mode() } end def view(_opt, params, columns, filtered, selecto) do group_by_params = Map.get(params, "group_by", %{}) presentation_context = runtime_presentation_context(params) per_page = Options.normalize_per_page_param(Map.get(params, "aggregate_per_page")) grid = truthy_param?(Map.get(params, "aggregate_grid")) grid_colorize = truthy_param?(Map.get(params, "aggregate_grid_colorize")) grid_color_scale = Options.normalize_grid_color_scale_mode(Map.get(params, "aggregate_grid_color_scale")) aggregate = Map.get(params, "aggregate", %{}) |> aggregates(columns) group_by = group_by_params |> group_by(columns, selecto, presentation_context) # Wrap only text-compatible group-by selectors in COALESCE to display '[NULL]'. # Applying string fallback to non-text fields (integer, enum, array, etc.) causes SQL type errors. group_by_with_coalesce = Enum.map(group_by, fn {col, sel} -> coalesced_sel = case sel do {:field, field_expr, alias} -> if should_coalesce_field?(col, selecto) do {:field, {:coalesce, [field_expr, {:literal, "[NULL]"}]}, alias} else sel end {:row, [display_field, id_field], alias} when is_binary(display_field) or is_atom(display_field) -> if row_display_coalesce?(display_field, columns, selecto) do {:row, [{:coalesce, [display_field, {:literal, "[NULL]"}]}, id_field], alias} else sel end _other -> sel end {col, coalesced_sel} end) selected = Enum.map(group_by_with_coalesce, fn {_c, sel} -> sel end) ++ aggregate literal_positions = case Enum.count(group_by) do 0 -> [] count -> Enum.map(1..count, fn g -> {:literal_position, g} end) end rollup_group_by = case collapse_linked_rollup_groups(group_by_with_coalesce) do [] -> [] selectors -> [{:rollup, selectors}] end view_set = %{ groups: group_by_with_coalesce, gb_params: group_by_params, aggregates: aggregate, selected: selected, filtered: filtered, group_by: rollup_group_by, ### when using rollup, we need to workaround postgres bug. Currently implemented in Selecto builder order_by: literal_positions } {view_set, %{ per_page: per_page, grid_enabled: grid, grid_colorize: grid_colorize, grid_color_scale: grid_color_scale }} end defp truthy_param?(value) when value in [true, "true", "on", "1", 1], do: true defp truthy_param?(_), do: false defp collapse_linked_rollup_groups(group_by_with_coalesce) do {groups, current_group} = Enum.reduce(group_by_with_coalesce, {[], []}, fn {col, sel}, {groups, current_group} -> current_group = current_group ++ [sel] if linked_to_next?(col) do {groups, current_group} else {groups ++ [finalize_rollup_group(current_group)], []} end end) groups ++ finalize_trailing_rollup_group(current_group) end defp finalize_rollup_group([selector]), do: selector defp finalize_rollup_group(selectors), do: {:grouping_set, selectors} defp finalize_trailing_rollup_group([]), do: [] defp finalize_trailing_rollup_group(current_group), do: [finalize_rollup_group(current_group)] defp linked_to_next?(col) when is_map(col) do truthy_param?(Map.get(col, :linked_to_next, Map.get(col, "linked_to_next"))) end defp linked_to_next?(_col), do: false def group_by(group_by, columns, selecto, presentation_context \\ %{}) do group_by |> Map.values() |> Enum.sort_by(&Param.integer(Map.get(&1, "index"))) |> Enum.map(fn e -> field_name = Map.get(e, "field") # Get column metadata - need to check domain schemas for join mode metadata # Selecto.field() doesn't return custom metadata from joined schema columns col = if selecto && String.contains?(to_string(field_name), ".") do # Joined field like "category.category_name" or Studio "table.column" refs. [schema_name, field_only] = String.split(to_string(field_name), ".", parts: 2) schema_col_metadata = SchemaUtils.lookup_domain_column_metadata(selecto, schema_name, field_only) if schema_col_metadata do # Merge with basic field info and ensure colid is set field_info = Selecto.field(selecto, field_name) || %{name: field_name, type: :string} Map.merge(field_info, schema_col_metadata) |> Map.put(:colid, field_name) else # Fall back to Selecto.field or columns map field_info = Selecto.field(selecto, field_name) if field_info do Map.put_new(field_info, :colid, field_name) else columns[field_name] end end else # Source table field or no selecto - use columns map columns[field_name] end col = if is_map(col) do col |> Map.put(:group_format, Map.get(e, "format")) |> Map.put("group_format", Map.get(e, "format")) |> Map.put(:linked_to_next, truthy_param?(Map.get(e, "linked_to_next"))) |> Map.put("linked_to_next", truthy_param?(Map.get(e, "linked_to_next"))) else col end col = if is_map(col) and selecto do SchemaUtils.with_resolved_type(selecto, col) else col end # ???? alias = case Map.get(e, "alias") do "" -> default_field_label(Map.get(e, "field"), col, columns) nil -> default_field_label(Map.get(e, "field"), col, columns) _ -> Map.get(e, "alias") end ### Group by filter, _select, format... sel = if Map.get(col, :group_by_filter_select) do case col.group_by_filter_select do x when is_list(x) -> {:row, col.group_by_filter_select, alias} x when is_function(x) -> {:row, col.group_by_filter_select.(e), alias} end else case Selecto.Temporal.date_like_type(col) || col.type do x when x in [:datetime, :timestamp, :naive_datetime, :utc_datetime, :date] -> {:field, datetime_gb_proc(col, e, presentation_context), alias} x when x in [:integer, :float, :decimal, :id] -> # Check if buckets format is specified format = Map.get(e, "format") bucket_ranges = Map.get(e, "bucket_ranges") if format == "buckets" and is_binary(bucket_ranges) and bucket_ranges != "" do field_with_alias = if String.contains?(to_string(col.colid), ".") do to_string(col.colid) else "selecto_root.#{col.colid}" end case_sql = BucketParser.generate_bucket_case_sql( field_with_alias, bucket_ranges, :integer ) {:field, {:raw_sql, case_sql}, alias} else {:field, col.colid, alias} end :custom_column -> case Map.get(col, :requires_select) do x when is_list(x) -> {:row, col.requires_select, alias} x when is_function(x) -> {:row, col.requires_select.(e), alias} nil -> {:field, col.colid, alias} end x when x in [:string, :text, :citext] -> format = Map.get(e, "format") if format == "text_prefix" do prefix_length = Map.get(e, "prefix_length", "2") exclude_articles = Map.get(e, "exclude_articles", "true") case_sql = BucketParser.generate_text_prefix_case_sql( col.colid, %{ "prefix_length" => prefix_length, "exclude_articles" => exclude_articles } ) {:field, {:raw_sql, case_sql}, alias} else default_group_selector(col, alias) end _ -> default_group_selector(col, alias) end end {col, sel} end) end # Extract table and field name from a column ID # Examples: "category.category_name" -> {"category", "category_name"} # :category_name -> {nil, "category_name"} # "category_name" -> {nil, "category_name"} defp extract_table_and_field(colid) do colid_str = to_string(colid) case String.split(colid_str, ".", parts: 2) do [table, field] -> {table, field} [field] -> {nil, field} end end defp default_group_selector(col, alias) do case Map.get(col, :join_mode) do mode when mode in [:lookup, :star, :tag] -> # Special join modes: select both display field and ID field as a row id_field = Map.get(col, :id_field) display_field = col.colid if id_field do # Extract table prefix from display field (e.g., "category.category_name" -> "category") {table_prefix, _field_name} = extract_table_and_field(display_field) # Build ID field reference id_colid = if table_prefix do "#{table_prefix}.#{id_field}" else id_field end # Return ROW(display_field, id_field) to get both values {:row, [display_field, id_colid], alias} else # Fallback if no id_field specified {:field, col.colid, alias} end _ -> # Normal columns: just select the field {:field, col.colid, alias} end end defp text_type?(type) do type in [:string, :text, :citext] end defp row_display_coalesce?(display_field, columns, selecto) do col = Map.get(columns, display_field) || Map.get(columns, to_string(display_field)) case col do nil -> false col -> should_coalesce_field?(col, selecto) end end defp should_coalesce_field?(col, selecto) do resolved_col = resolve_column_metadata(col, selecto) field_name = Map.get(resolved_col, :field) || Map.get(col, :field) colid = Map.get(resolved_col, :colid) || Map.get(col, :colid) text_type = text_type?(Map.get(resolved_col, :type)) enum_by_name = enum_field_name_any_schema?(field_name, selecto) root_enum = root_enum_field?(col, selecto) enum_by_metadata = enum_field?(resolved_col, selecto) filter_type = Map.get(resolved_col, :filter_type) || Map.get(resolved_col, "filter_type") id_like = filter_type == :multi_select_id or filter_type == "multi_select_id" or id_like_name?(field_name) or id_like_name?(colid) text_type and not enum_by_name and not root_enum and not enum_by_metadata and not id_like end defp resolve_column_metadata(col, nil), do: col defp resolve_column_metadata(col, selecto) do SchemaUtils.with_resolved_type(selecto, col) end defp enum_field?(col, selecto) do enum_field_by_metadata?(col, selecto) or enum_field_by_colid?(col, selecto) end defp enum_field_by_metadata?(%{field: field, requires_join: join_ref}, selecto) do with {:ok, field_atom} <- to_existing_atom_safe(field), {:ok, schema_module} <- schema_module_for_join(join_ref, selecto), true <- Code.ensure_loaded?(schema_module), {:parameterized, {Ecto.Enum, _}} <- schema_module.__schema__(:type, field_atom) do true else _ -> false end end defp enum_field_by_metadata?(_, _), do: false defp enum_field_by_colid?(_col, nil), do: false defp enum_field_by_colid?(%{colid: colid}, selecto) when is_binary(colid) or is_atom(colid) do case Selecto.field(selecto, colid) do nil -> false metadata -> enum_field_by_metadata?(metadata, selecto) end end defp enum_field_by_colid?(_, _), do: false defp root_enum_field?(_col, nil), do: false defp root_enum_field?(%{field: field, requires_join: :selecto_root}, selecto) do with {:ok, field_atom} <- to_existing_atom_safe(field), module when is_atom(module) <- get_in(Selecto.domain(selecto), [:source, :schema_module]), true <- Code.ensure_loaded?(module), {:parameterized, {Ecto.Enum, _}} <- module.__schema__(:type, field_atom) do true else _ -> false end end defp root_enum_field?(_, _), do: false defp id_like_name?(nil), do: false defp id_like_name?(name) do case to_string(name) do "id" -> true value -> String.ends_with?(value, ".id") or String.ends_with?(value, "_id") end end defp enum_field_name_any_schema?(_field, nil), do: false defp enum_field_name_any_schema?(field, selecto) do with {:ok, field_atom} <- to_existing_atom_safe(field) do domain = Selecto.domain(selecto) source_module = get_in(domain, [:source, :schema_module]) schema_modules = (get_in(domain, [:schemas]) || %{}) |> Map.values() |> Enum.map(&Map.get(&1, :schema_module)) ([source_module] ++ schema_modules) |> Enum.any?(fn module when is_atom(module) -> Code.ensure_loaded?(module) and match?({:parameterized, {Ecto.Enum, _}}, module.__schema__(:type, field_atom)) _ -> false end) else _ -> false end end defp schema_module_for_join(:selecto_root, selecto) do case get_in(Selecto.domain(selecto), [:source, :schema_module]) do module when is_atom(module) -> {:ok, module} _ -> :error end end defp schema_module_for_join(join_ref, selecto) when is_atom(join_ref) do case get_in(Selecto.domain(selecto), [:schemas, join_ref, :schema_module]) do module when is_atom(module) -> {:ok, module} _ -> :error end end defp schema_module_for_join(join_ref, selecto) when is_binary(join_ref) do case to_existing_atom_safe(join_ref) do {:ok, join_atom} -> schema_module_for_join(join_atom, selecto) :error -> :error end end defp schema_module_for_join(_, _), do: :error defp to_existing_atom_safe(value) when is_atom(value), do: {:ok, value} defp to_existing_atom_safe(value) when is_binary(value) do try do {:ok, String.to_existing_atom(value)} rescue ArgumentError -> :error end end defp to_existing_atom_safe(_), do: :error defp datetime_gb_proc(col, config, presentation_context) do format = Map.get(config, "format") bucket_ranges = Map.get(config, "bucket_ranges") field_with_alias = aggregate_field_ref(col.colid) case format do "age_buckets" when is_binary(bucket_ranges) and bucket_ranges != "" -> # Generate CASE expression for age buckets in group by. # Joined fields need their fully-qualified reference, not the pivot alias. case_sql = BucketParser.generate_bucket_case_sql( "(CURRENT_DATE - DATE(#{field_with_alias}))", bucket_ranges, :integer ) {:raw_sql, case_sql} "custom_buckets" when is_binary(bucket_ranges) and bucket_ranges != "" -> case_sql = BucketParser.generate_bucket_case_sql( field_with_alias, bucket_ranges, :date ) {:raw_sql, case_sql} "year_buckets" when is_binary(bucket_ranges) and bucket_ranges != "" -> case_sql = BucketParser.generate_bucket_case_sql( year_bucket_extract_sql(col, field_with_alias, presentation_context), bucket_ranges, :integer ) {:raw_sql, case_sql} format when is_binary(format) and format not in ["", "default"] -> maybe_timezone_aware_datetime_selector( col, field_with_alias, SqlSafety.datetime_grouping_format(format), presentation_context ) _ -> # Default to day format maybe_timezone_aware_datetime_selector( col, field_with_alias, "YYYY-MM-DD", presentation_context ) end end defp aggregate_field_ref(colid) do colid_str = to_string(colid) if String.contains?(colid_str, "."), do: colid_str, else: "selecto_root." <> colid_str end defp runtime_presentation_context(params) when is_map(params) do Map.get(params, "_presentation_context", %{}) end defp maybe_timezone_aware_datetime_selector(col, field_ref, format, presentation_context) do if timezone_grouping_applicable?(col, presentation_context) do {:raw_sql, timezone_aware_to_char_sql(col, field_ref, format, presentation_context)} else {:to_char, {col.colid, format}} end end defp year_bucket_extract_sql(col, field_ref, presentation_context) do if timezone_grouping_applicable?(col, presentation_context) do "EXTRACT(YEAR FROM #{timezone_grouping_expression(col, field_ref, presentation_context)})" else "EXTRACT(YEAR FROM #{field_ref})" end end defp timezone_grouping_applicable?(col, presentation_context) do Selecto.Presentation.temporal_kind(col) == :instant and is_binary(runtime_timezone(presentation_context)) and runtime_timezone(presentation_context) != "" end defp timezone_aware_to_char_sql(col, field_ref, format, presentation_context) do "to_char(#{timezone_grouping_expression(col, field_ref, presentation_context)}, '#{format}')" end defp timezone_grouping_expression(col, field_ref, presentation_context) do timezone = runtime_timezone(presentation_context) storage_timezone = storage_timezone(col) case Selecto.Temporal.epoch_storage(col) do :unix_seconds -> "to_timestamp(#{field_ref}) AT TIME ZONE '#{timezone}'" :unix_milliseconds -> "to_timestamp((#{field_ref}) / 1000.0) AT TIME ZONE '#{timezone}'" _ -> "(#{field_ref} AT TIME ZONE '#{storage_timezone}') AT TIME ZONE '#{timezone}'" end end defp runtime_timezone(presentation_context) when is_map(presentation_context) do presentation_context |> Map.get(:timezone, Map.get(presentation_context, "timezone")) |> SqlSafety.timezone(nil) end defp runtime_timezone(_presentation_context), do: nil defp storage_timezone(col) do col |> Selecto.Presentation.presentation() |> Map.get(:storage_timezone, "Etc/UTC") |> SqlSafety.timezone() end def aggregates(aggregates, columns) do result = aggregates |> Map.values() |> Enum.sort_by(&Param.integer(Map.get(&1, "index"))) |> Enum.flat_map(fn e -> # ???? # Handle special formats like buckets and age_buckets format = Map.get(e, "format") field = Map.get(e, "field") bucket_ranges = Map.get(e, "bucket_ranges") ignore_nulls_in_sum = truthy_param?(Map.get(e, "ignore_nulls_in_sum")) function_name = case Map.get(e, "function", Map.get(e, "format")) do nil -> "count" "" -> "count" function -> function end aggregate_alias = case Map.get(e, "alias") do "" -> default_aggregate_label(field, columns, function_name) nil -> default_aggregate_label(field, columns, function_name) custom -> custom end case format do "age_buckets" when is_binary(bucket_ranges) and bucket_ranges != "" -> # Generate multiple columns for age buckets alias SelectoComponents.Helpers.BucketParser # Get bucket labels to create separate columns # Remove "Other" from the labels since users should explicitly ask for what they want labels = BucketParser.get_bucket_labels(bucket_ranges) |> Enum.reject(fn label -> label == "Other" end) # Return multiple aggregate specs, one for each bucket # Use a special aggregate type that preserves field references labels |> Enum.with_index() |> Enum.map(fn {label, index} -> # Create a prettier display name for the bucket column # Put the user's alias only at the beginning of the first column pretty_label = case label do "Other" -> "Other" _ -> # Parse the label to determine the format cond do # Single day: "0" -> "0 days" or "1 day" Regex.match?(~r/^\d+$/, label) -> days = String.to_integer(label) if days == 1 do "1 day" else "#{label} days" end # Range: "3-10" -> "3-10 days" Regex.match?(~r/^\d+-\d+$/, label) -> "#{label} days" # Open-ended: "11+" -> "11+ days" Regex.match?(~r/^\d+\+$/, label) -> "#{label} days" # Default fallback true -> label end end # Add the alias prefix only to the first column pretty_label = if index == 0 && aggregate_alias not in [nil, ""] do "#{aggregate_alias}: #{pretty_label}" else pretty_label end # Parse the bucket range for this label ranges = BucketParser.parse_bucket_ranges(bucket_ranges) # Find the range for this label range_spec = Enum.find(ranges, fn {_, _, l} -> l == label end) aggregate_spec = case range_spec do {min, max, _} when is_integer(min) and is_integer(max) -> if min == max do {:count_age_bucket, field, min, min} else {:count_age_bucket, field, min, max} end {min, :infinity, _} -> {:count_age_bucket, field, min, :infinity} {:negative_infinity, max, _} -> {:count_age_bucket, field, :negative_infinity, max} _ -> # Should not happen since we filtered out "Other" from labels nil end # Only return the field spec if we have a valid aggregate_spec if aggregate_spec do {:field, aggregate_spec, pretty_label} else nil end end) # Remove any nil entries |> Enum.reject(&is_nil/1) "buckets" when is_binary(bucket_ranges) and bucket_ranges != "" -> # Generate multiple columns for numeric buckets alias SelectoComponents.Helpers.BucketParser # Get bucket labels to create separate columns # Remove "Other" from the labels since users should explicitly ask for what they want labels = BucketParser.get_bucket_labels(bucket_ranges) |> Enum.reject(fn label -> label == "Other" end) # Return multiple aggregate specs, one for each bucket labels |> Enum.with_index() |> Enum.map(fn {label, index} -> # Create a prettier display name for the bucket column # Put the user's alias only at the beginning of the first column pretty_label = case label do "Other" -> "Other" _ -> # Numeric buckets use the label as-is label end # Add the alias prefix only to the first column pretty_label = if index == 0 && aggregate_alias not in [nil, ""] do "#{aggregate_alias}: #{pretty_label}" else pretty_label end # Parse the bucket range for this label ranges = BucketParser.parse_bucket_ranges(bucket_ranges) # Find the range for this label range_spec = Enum.find(ranges, fn {_, _, l} -> l == label end) aggregate_spec = case range_spec do {min, max, _} when is_integer(min) and is_integer(max) -> if min == max do {:count_bucket, field, min, min} else {:count_bucket, field, min, max} end {min, :infinity, _} -> {:count_bucket, field, min, :infinity} {:negative_infinity, max, _} -> {:count_bucket, field, :negative_infinity, max} _ -> # Should not happen since we filtered out "Other" from labels nil end # Only return the field spec if we have a valid aggregate_spec if aggregate_spec do {:field, aggregate_spec, pretty_label} else nil end end) # Remove any nil entries |> Enum.reject(&is_nil/1) _other -> # Standard aggregates - return as single item list for consistency [ {:field, aggregate_selector(function_name, field, ignore_nulls_in_sum), aggregate_alias} ] end end) result end defp aggregate_selector("true_count", field, _ignore_nulls_in_sum), do: {:count, field, {field, true}} defp aggregate_selector("false_count", field, _ignore_nulls_in_sum), do: {:count, field, {field, false}} defp aggregate_selector("sum", field, true), do: {:sum, {:coalesce, [field, 0]}} defp aggregate_selector(function_name, field, _ignore_nulls_in_sum), do: {SafeAtom.to_aggregate_function(function_name), field} defp default_aggregate_label(field, columns, function_name) do "#{default_field_label(field, lookup_column(columns, field), columns)} #{aggregate_function_label(function_name)}" end defp default_field_label(field, col, columns) do fallback = humanize_field_name(field) candidate = case lookup_column(columns, field) do %{name: name} when is_binary(name) and name != "" -> name %{"name" => name} when is_binary(name) and name != "" -> name _ -> cond do is_map(col) and is_binary(Map.get(col, :name)) and Map.get(col, :name) != "" -> Map.get(col, :name) is_map(col) and is_binary(Map.get(col, "name")) and Map.get(col, "name") != "" -> Map.get(col, "name") true -> nil end end normalize_field_label(candidate, fallback) end defp lookup_column(columns, field) when is_map(columns) do Map.get(columns, field) || case field do field_name when is_binary(field_name) -> try do field_name |> String.to_existing_atom() |> then(&Map.get(columns, &1)) rescue ArgumentError -> nil end field_name when is_atom(field_name) -> Map.get(columns, Atom.to_string(field_name)) _ -> nil end end defp lookup_column(_columns, _field), do: nil defp humanize_field_name(field) do field |> to_string() |> String.split(".") |> List.last() |> String.split("_") |> Enum.map_join(" ", fn "id" -> "ID" part -> String.capitalize(part) end) end defp normalize_field_label(nil, fallback), do: fallback defp normalize_field_label("", fallback), do: fallback defp normalize_field_label(candidate, fallback) when is_binary(candidate) do cond do candidate == fallback -> fallback String.ends_with?(candidate, ": #{fallback}") -> fallback String.contains?(candidate, ": ") -> fallback String.contains?(candidate, "_") -> fallback true -> candidate end end defp aggregate_function_label(function_name) do case to_string(function_name) do "count_distinct" -> "Distinct Count" "count" -> "Count" "sum" -> "Sum" "avg" -> "Average" "min" -> "Min" "max" -> "Max" "buckets" -> "Buckets" "age_buckets" -> "Age Buckets" "year_buckets" -> "Year Buckets" "true_count" -> "True Count" "false_count" -> "False Count" value -> SelectoComponents.Helpers.aggregate_datetime_format_label(value) end end end