defmodule Selecto.Advanced.ValuesClause do @moduledoc """ VALUES clause support for inline table generation and data transformations. VALUES clauses allow creating inline tables from literal data, useful for lookup tables, data transformations, and testing scenarios. ## Examples # Basic VALUES table selecto |> Selecto.with_values([ ["PG", "Family Friendly", 1], ["PG-13", "Teen", 2], ["R", "Adult", 3] ], columns: ["rating_code", "description", "sort_order"], as: "rating_lookup" ) # Map-based VALUES selecto |> Selecto.with_values([ %{month: 1, name: "January", days: 31}, %{month: 2, name: "February", days: 28}, %{month: 3, name: "March", days: 31} ], as: "months") """ defmodule Spec do @moduledoc """ Specification for a VALUES clause operation. """ defstruct [ # Unique identifier for the VALUES clause :id, # List of data rows (lists or maps) :data, # Column names (inferred from data or explicit) :columns, # Alias for the VALUES table :alias, # :list_of_lists, :list_of_maps :data_type, # Inferred data types for each column :column_types, # Boolean indicating if data has been validated :validated ] @type data_row :: [term()] | map() @type column_spec :: String.t() | atom() @type t :: %__MODULE__{ id: String.t(), data: [data_row()], columns: [column_spec()], alias: String.t(), data_type: :list_of_lists | :list_of_maps, column_types: map(), validated: boolean() } end defmodule ValidationError do @moduledoc """ Error raised when VALUES clause data is invalid. """ defexception [:type, :message, :details] @type t :: %__MODULE__{ type: :inconsistent_columns | :empty_data | :type_mismatch | :validation_error, message: String.t(), details: map() } end @doc """ Create a VALUES clause specification. ## Parameters - `data` - List of data rows (lists or maps) - `opts` - Options including :columns, :as (alias) ## Examples # List of lists format ValuesClause.create_values_clause([ ["PG", "Family", 1], ["R", "Adult", 3] ], columns: ["code", "desc", "order"], as: "ratings") # Map format (columns inferred) ValuesClause.create_values_clause([ %{id: 1, name: "Alice"}, %{id: 2, name: "Bob"} ], as: "users") """ def create_values_clause(data, opts \\ []) do alias_name = Keyword.get(opts, :as, "values_table") explicit_columns = Keyword.get(opts, :columns, []) spec = %Spec{ id: generate_values_id(alias_name), data: data, columns: [], alias: alias_name, data_type: detect_data_type(data), column_types: %{}, validated: false } # Validate and process the data case validate_and_process_data(spec, explicit_columns) do {:ok, processed_spec} -> processed_spec {:error, validation_error} -> raise validation_error end end @doc """ Validate VALUES clause data and infer column information. Ensures data consistency and infers column names and types. """ def validate_and_process_data(%Spec{} = spec, explicit_columns) do with :ok <- validate_data_not_empty(spec.data), {:ok, data_type} <- validate_data_consistency(spec.data), {:ok, columns} <- determine_columns(spec.data, data_type, explicit_columns), {:ok, column_types} <- infer_column_types(spec.data, data_type, columns), :ok <- validate_data_completeness(spec.data, data_type, columns) do processed_spec = %{ spec | data_type: data_type, columns: columns, column_types: column_types, validated: true } {:ok, processed_spec} else {:error, reason} -> {:error, reason} end end # Validate that data is not empty defp validate_data_not_empty([]), do: {:error, %ValidationError{ type: :empty_data, message: "VALUES clause cannot have empty data", details: %{} }} defp validate_data_not_empty(_data), do: :ok # Validate that all data rows have consistent structure defp validate_data_consistency([first_row | rest]) do data_type = detect_data_type([first_row]) case data_type do :list_of_lists -> validate_list_consistency([first_row | rest]) :list_of_maps -> validate_map_consistency([first_row | rest]) _ -> {:error, %ValidationError{ type: :validation_error, message: "Unsupported data format for VALUES clause", details: %{data_type: data_type} }} end end # Validate that all rows are lists with same length defp validate_list_consistency([first_row | rest]) when is_list(first_row) do expected_length = length(first_row) invalid_rows = rest |> Enum.with_index(1) |> Enum.filter(fn {row, _index} -> not is_list(row) or (is_list(row) and length(row) != expected_length) end) if Enum.empty?(invalid_rows) do {:ok, :list_of_lists} else [{invalid_row, index}] = Enum.take(invalid_rows, 1) actual_length = if is_list(invalid_row), do: length(invalid_row), else: 0 {:error, %ValidationError{ type: :inconsistent_columns, message: "Row #{index + 1} has #{actual_length} columns, expected #{expected_length}", details: %{ expected_length: expected_length, actual_length: actual_length, row_index: index + 1 } }} end end # Validate that all rows are maps with consistent keys defp validate_map_consistency([first_row | rest]) when is_map(first_row) do expected_keys = Map.keys(first_row) |> Enum.map(&to_string/1) |> Enum.sort() invalid_rows = rest |> Enum.with_index(1) |> Enum.filter(fn {row, _index} -> not is_map(row) or Map.keys(row) |> Enum.map(&to_string/1) |> Enum.sort() != expected_keys end) if Enum.empty?(invalid_rows) do {:ok, :list_of_maps} else [{invalid_row, index}] = Enum.take(invalid_rows, 1) actual_keys = if is_map(invalid_row), do: Map.keys(invalid_row) |> Enum.map(&to_string/1) |> Enum.sort(), else: [] {:error, %ValidationError{ type: :inconsistent_columns, message: "Row #{index + 1} has different keys than first row", details: %{ expected_keys: expected_keys, actual_keys: actual_keys, row_index: index + 1 } }} end end # Determine column names from data and explicit columns defp determine_columns(data, :list_of_lists, explicit_columns) do case explicit_columns do [] -> # Generate default column names [first_row | _] = data column_count = length(first_row) columns = Enum.map(1..column_count, &"column#{&1}") {:ok, columns} columns when is_list(columns) -> [first_row | _] = data if length(columns) == length(first_row) do {:ok, Enum.map(columns, &to_string/1)} else {:error, %ValidationError{ type: :inconsistent_columns, message: "Number of explicit columns (#{length(columns)}) doesn't match data columns (#{length(first_row)})", details: %{explicit_count: length(columns), data_count: length(first_row)} }} end end end defp determine_columns(data, :list_of_maps, explicit_columns) do [first_row | _] = data inferred_columns = Map.keys(first_row) |> Enum.map(&to_string/1) |> Enum.sort() case explicit_columns do [] -> {:ok, inferred_columns} columns when is_list(columns) -> explicit_strings = Enum.map(columns, &to_string/1) |> Enum.sort() if explicit_strings == inferred_columns do {:ok, explicit_strings} else {:error, %ValidationError{ type: :inconsistent_columns, message: "Explicit columns don't match map keys", details: %{explicit_columns: explicit_strings, map_keys: inferred_columns} }} end end end # Infer column types from data defp infer_column_types(data, data_type, columns) do column_types = case data_type do :list_of_lists -> infer_types_from_lists(data, columns) :list_of_maps -> infer_types_from_maps(data, columns) end {:ok, column_types} end # Infer types from list of lists format defp infer_types_from_lists(data, columns) do columns |> Enum.with_index() |> Enum.into(%{}, fn {column, index} -> # Sample values from this column position sample_values = data # Sample first 10 rows |> Enum.take(10) |> Enum.map(&Enum.at(&1, index)) |> Enum.reject(&is_nil/1) inferred_type = infer_type_from_values(sample_values) {column, inferred_type} end) end # Infer types from list of maps format defp infer_types_from_maps(data, columns) do columns |> Enum.into(%{}, fn column -> column_key = String.to_existing_atom(column) # Sample values from this column sample_values = data # Sample first 10 rows |> Enum.take(10) |> Enum.map(&Map.get(&1, column_key)) |> Enum.reject(&is_nil/1) inferred_type = infer_type_from_values(sample_values) {column, inferred_type} end) rescue ArgumentError -> # Fallback if string to atom conversion fails columns |> Enum.into(%{}, fn column -> sample_values = data |> Enum.take(10) |> Enum.map(&Map.get(&1, column)) |> Enum.reject(&is_nil/1) inferred_type = infer_type_from_values(sample_values) {column, inferred_type} end) end # Infer data type from sample values defp infer_type_from_values([]), do: :unknown defp infer_type_from_values(values) do # Check the first non-nil value's type case Enum.find(values, &(not is_nil(&1))) do val when is_integer(val) -> :integer val when is_float(val) -> :decimal val when is_boolean(val) -> :boolean val when is_binary(val) -> :string %Date{} -> :date %DateTime{} -> :utc_datetime %NaiveDateTime{} -> :naive_datetime _ -> :unknown end end # Validate that all data rows have complete data for all columns defp validate_data_completeness(data, :list_of_lists, columns) do expected_length = length(columns) incomplete_rows = data |> Enum.with_index() |> Enum.filter(fn {row, _index} -> length(row) != expected_length end) if Enum.empty?(incomplete_rows) do :ok else [{row, index}] = Enum.take(incomplete_rows, 1) {:error, %ValidationError{ type: :inconsistent_columns, message: "Row #{index + 1} has #{length(row)} values, expected #{expected_length}", details: %{expected: expected_length, actual: length(row), row_index: index + 1} }} end end defp validate_data_completeness(data, :list_of_maps, columns) do required_keys = Enum.map(columns, fn col -> try do String.to_existing_atom(col) rescue ArgumentError -> col end end) incomplete_rows = data |> Enum.with_index() |> Enum.filter(fn {row, _index} -> row_keys = Map.keys(row) |> Enum.sort() Enum.sort(required_keys) != row_keys end) if Enum.empty?(incomplete_rows) do :ok else [{row, index}] = Enum.take(incomplete_rows, 1) {:error, %ValidationError{ type: :inconsistent_columns, message: "Row #{index + 1} missing or has extra keys", details: %{required_keys: required_keys, row_keys: Map.keys(row), row_index: index + 1} }} end end # Detect data type from first element defp detect_data_type([]), do: :empty defp detect_data_type([first | _]) when is_list(first), do: :list_of_lists defp detect_data_type([first | _]) when is_map(first), do: :list_of_maps defp detect_data_type(_), do: :unknown # Generate unique ID for VALUES clause defp generate_values_id(alias_name) do unique = :erlang.unique_integer([:positive]) "values_#{alias_name}_#{unique}" end end