defmodule Selecto.Output.Transformers.Json do @moduledoc """ JSON transformer for Selecto query results. Provides JSON serialization with configurable options for null handling, metadata inclusion, pretty printing, and streaming support for large datasets. ## Options - `:include_meta` - Include query metadata in response (default: false) - `:pretty` - Pretty print JSON output (default: false) - `:null_handling` - How to handle nil values: `:null`, `:omit`, `:empty_string` (default: `:null`) - `:keys` - Key format: `:strings`, `:atoms` (default: `:strings`) - `:coerce_types` - Enable type coercion (default: false) - `:date_format` - Date serialization format: `:iso8601`, `:unix`, `:string` (default: `:iso8601`) - `:decimal_format` - Decimal format: `:string`, `:float` (default: `:string`) ## Examples # Basic JSON serialization {:ok, json} = Json.transform(rows, columns, %{}, []) # JSON with metadata {:ok, json} = Json.transform(rows, columns, %{}, [include_meta: true]) # Pretty printed JSON with null omission {:ok, json} = Json.transform(rows, columns, %{}, [ pretty: true, null_handling: :omit ]) # Streaming for large datasets stream = Json.stream_transform(rows, columns, %{}, [pretty: true]) """ alias Selecto.Error # alias Selecto.Output.TypeCoercion @type json_option :: {:include_meta, boolean()} | {:pretty, boolean()} | {:null_handling, :null | :omit | :empty_string} | {:keys, :strings | :atoms} | {:coerce_types, boolean()} | {:date_format, :iso8601 | :unix | :string} | {:decimal_format, :string | :float} @type json_options :: [json_option()] @doc """ Transform query results to JSON string. Returns a JSON string representation of the query results with configurable serialization options. """ @spec transform(list(list()), list(String.t()), map(), json_options()) :: {:ok, String.t()} | {:error, Error.t()} def transform(rows, columns, aliases, options \\ []) do try do # Parse and validate options opts = parse_options(options) # First convert to maps format case convert_to_maps(rows, columns, aliases, opts) do {:ok, maps} -> # Apply null handling processed_maps = apply_null_handling(maps, opts.null_handling) # Create final JSON structure json_data = if opts.include_meta do %{ "data" => processed_maps, "meta" => build_metadata(rows, columns, aliases) } else processed_maps end # Serialize to JSON case serialize_json(json_data, opts) do {:ok, json_string} -> {:ok, json_string} {:error, reason} -> {:error, Error.transformation_error("JSON serialization failed: #{inspect(reason)}")} end {:error, reason} -> {:error, Error.transformation_error("Failed to convert to maps: #{inspect(reason)}")} end rescue error -> {:error, Error.transformation_error("JSON transform error: #{inspect(error)}")} end end @doc """ Transform query results to a JSON stream for large datasets. Returns a stream that yields JSON strings, useful for processing large result sets without loading everything into memory. """ @spec stream_transform(list(list()) | Enumerable.t(), list(String.t()), map(), json_options()) :: Enumerable.t() def stream_transform(rows, columns, aliases, options \\ []) do opts = parse_options(options) Stream.resource( fn -> # Initialize streaming state {rows, 0, opts.include_meta} end, fn {remaining_rows, index, needs_meta} -> case remaining_rows do [] -> if needs_meta and index == 0 do # If no data but metadata requested, return empty data with meta json_data = %{ "data" => [], "meta" => build_metadata([], columns, aliases) } case serialize_json(json_data, opts) do {:ok, json} -> {[json], {[], 1, false}} {:error, _} -> {:halt, {[], 1, false}} end else {:halt, {[], index, false}} end [row | rest] -> case convert_single_row_to_map(row, columns, aliases, opts) do {:ok, map} -> processed_map = apply_null_handling([map], opts.null_handling) |> List.first() json_item = if needs_meta and index == 0 do # First item includes metadata wrapper %{ "data" => [processed_map], "meta" => build_metadata([row | rest], columns, aliases) } else processed_map end case serialize_json(json_item, opts) do {:ok, json} -> {[json], {rest, index + 1, false}} {:error, _} -> {:halt, {rest, index + 1, false}} end {:error, _} -> {:halt, {rest, index + 1, false}} end end :done -> {:halt, :done} end, fn _acc -> :ok end ) end # Private functions defp parse_options(options) do %{ include_meta: Keyword.get(options, :include_meta, false), pretty: Keyword.get(options, :pretty, false), null_handling: Keyword.get(options, :null_handling, :null), keys: Keyword.get(options, :keys, :strings), coerce_types: Keyword.get(options, :coerce_types, false), date_format: Keyword.get(options, :date_format, :iso8601), decimal_format: Keyword.get(options, :decimal_format, :string) } end defp convert_to_maps(rows, columns, aliases, opts) do # Use the Maps transformer logic to convert to maps first _maps_options = [ keys: opts.keys, coerce_types: opts.coerce_types ] # Convert each row to a map try do maps = Enum.map(rows, fn row -> case convert_single_row_to_map(row, columns, aliases, opts) do {:ok, map} -> map # Return empty map on error {:error, _} -> %{} end end) {:ok, maps} rescue error -> {:error, error} end end defp convert_single_row_to_map(row, columns, aliases, opts) do try do # Create column name -> value mapping map_data = columns |> Enum.with_index() |> Enum.map(fn {column, index} -> value = Enum.at(row, index) # Get the effective column name (use alias if available) effective_name = resolve_display_name(column, aliases, index) # Apply key transformation final_name = case opts.keys do :atoms -> ensure_atom(effective_name) :strings -> to_string(effective_name) end # Apply type coercion if enabled coerced_value = if opts.coerce_types do # For JSON, we need to be more careful about type coercion # to ensure JSON serializability coerce_for_json(value, opts) else value end {final_name, coerced_value} end) |> Enum.into(%{}) {:ok, map_data} rescue error -> {:error, error} end end defp resolve_display_name(column, aliases, _idx) when is_map(aliases) do Map.get(aliases, column, column) end defp resolve_display_name(column, aliases, idx) when is_list(aliases) do case Enum.at(aliases, idx) do nil -> column alias_name when is_binary(alias_name) -> if looks_like_uuid?(alias_name), do: column, else: alias_name alias_name -> alias_name end end defp resolve_display_name(column, _aliases, _idx), do: column defp looks_like_uuid?(value) when is_binary(value) do Regex.match?( ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, value ) end defp coerce_for_json(value, _opts) when is_nil(value), do: nil defp coerce_for_json(%Decimal{} = decimal, opts) do case opts.decimal_format do :string -> Decimal.to_string(decimal) :float -> Decimal.to_float(decimal) end end defp coerce_for_json(%DateTime{} = dt, opts) do case opts.date_format do :iso8601 -> DateTime.to_iso8601(dt) :unix -> DateTime.to_unix(dt) :string -> to_string(dt) end end defp coerce_for_json(%Date{} = date, opts) do case opts.date_format do :iso8601 -> Date.to_iso8601(date) :unix -> date |> Date.to_erl() |> :calendar.datetime_to_gregorian_seconds() |> Kernel.-(62_167_219_200) :string -> to_string(date) end end defp coerce_for_json(%Time{} = time, _opts) do Time.to_iso8601(time) end defp coerce_for_json(value, _opts) when is_binary(value), do: value defp coerce_for_json(value, _opts) when is_number(value), do: value defp coerce_for_json(value, _opts) when is_boolean(value), do: value defp coerce_for_json(value, _opts) when is_list(value), do: value defp coerce_for_json(value, _opts) when is_map(value), do: value defp coerce_for_json(value, _opts), do: to_string(value) defp apply_null_handling(maps, :null), do: maps defp apply_null_handling(maps, :omit) do Enum.map(maps, fn map -> map |> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Enum.into(%{}) end) end defp apply_null_handling(maps, :empty_string) do Enum.map(maps, fn map -> map |> Enum.map(fn {k, v} -> {k, if(is_nil(v), do: "", else: v)} end) |> Enum.into(%{}) end) end defp build_metadata(rows, columns, aliases) do %{ "total_rows" => length(rows), "columns" => columns, "aliases" => aliases, # Could be populated by caller "query_time_ms" => nil, "generated_at" => DateTime.utc_now() |> DateTime.to_iso8601() } end defp serialize_json(data, opts) do jason_options = if opts.pretty do [pretty: true] else [] end case Jason.encode(data, jason_options) do {:ok, json} -> {:ok, json} {:error, reason} -> {:error, reason} end end defp ensure_atom(name) when is_atom(name), do: name defp ensure_atom(name) when is_binary(name) do try do String.to_existing_atom(name) rescue ArgumentError -> String.to_atom(name) end end defp ensure_atom(name), do: name |> to_string() |> ensure_atom() end