lib/selecto/advanced/array_operations.ex

defmodule Selecto.Advanced.ArrayOperations do
  @moduledoc """
  Array operations support for PostgreSQL array functionality.

  Provides comprehensive support for array construction, aggregation, manipulation,
  testing, and unnesting operations. Works with PostgreSQL native array types
  and provides type-safe operations for array columns.

  ## Examples

      # Array aggregation
      selecto
      |> Selecto.select([
          "category.name",
          {:array_agg, "film.title", as: "films"},
          {:array_length, {:array_agg, "film.film_id"}, 1, as: "film_count"}
        ])
      |> Selecto.group_by(["category.category_id", "category.name"])
      
      # Array filtering
      selecto
      |> Selecto.filter([
          {:array_contains, "film.special_features", ["Trailers"]},
          {:array_overlap, "film.special_features", ["Deleted Scenes", "Behind the Scenes"]}
        ])
      
      # Array unnesting
      selecto
      |> Selecto.select(["film.title", "feature"])
      |> Selecto.unnest("film.special_features", as: "feature")
  """

  defmodule Spec do
    @moduledoc """
    Specification for array operations in SELECT, WHERE, and other clauses.
    """
    defstruct [
      # Unique identifier for the array operation
      :id,
      # Array operation type (:array_agg, :array_contains, etc.)
      :operation,
      # Source column name or expression
      :column,
      # Array dimension for length/cardinality operations
      :dimension,
      # Value for comparison/containment operations
      :value,
      # Whether to use DISTINCT in aggregation
      :distinct,
      # ORDER BY clause for array_agg
      :order_by,
      # Optional alias for SELECT operations
      :alias,
      # Additional options (null handling, etc.)
      :options,
      # Boolean indicating if operation has been validated
      :validated
    ]

    # Aggregation operations
    @type operation_type ::
            :array_agg
            | :array_agg_distinct
            | :string_agg
            # Testing operations  
            | :array_contains
            | :array_contained
            | :array_overlap
            | :array_eq
            # Size operations
            | :array_length
            | :cardinality
            | :array_ndims
            | :array_dims
            # Construction operations
            | :array
            | :array_fill
            | :array_append
            | :array_prepend
            | :array_cat
            # Element operations
            | :array_position
            | :array_positions
            | :array_remove
            | :array_replace
            # Transformation operations
            | :unnest
            | :array_to_string
            | :string_to_array
            # Set operations
            | :array_union
            | :array_intersect
            | :array_except

    @type t :: %__MODULE__{
            id: String.t(),
            operation: operation_type(),
            column: String.t() | tuple(),
            dimension: integer() | nil,
            value: term() | nil,
            distinct: boolean(),
            order_by: list() | nil,
            alias: String.t() | nil,
            options: map(),
            validated: boolean()
          }
  end

  defmodule ValidationError do
    @moduledoc """
    Error raised when array operation specification is invalid.
    """
    defexception [:type, :message, :details]

    @type t :: %__MODULE__{
            type: :invalid_operation | :invalid_column | :invalid_arguments | :invalid_dimension,
            message: String.t(),
            details: map()
          }
  end

  @doc """
  Create an array aggregation operation specification.

  ## Examples

      # Simple array aggregation
      create_array_operation(:array_agg, "film.title", as: "film_titles")
      
      # Array aggregation with DISTINCT
      create_array_operation(:array_agg, "actor.name", distinct: true, as: "unique_actors")
      
      # Array aggregation with ORDER BY
      create_array_operation(:array_agg, "film.title", 
        order_by: [{"film.release_year", :desc}], 
        as: "films_by_year")
  """
  def create_array_operation(operation, column, opts \\ []) do
    spec = %Spec{
      id: generate_array_operation_id(operation, column),
      operation: operation,
      column: column,
      dimension: opts[:dimension],
      value: opts[:value],
      distinct: opts[:distinct] || false,
      order_by: opts[:order_by],
      alias: opts[:as],
      options: Map.new(Keyword.drop(opts, [:dimension, :value, :distinct, :order_by, :as])),
      validated: false
    }

    validate_array_operation!(spec)
  end

  @doc """
  Create an array containment/testing operation for filters.

  ## Examples

      # Array contains
      create_array_filter(:array_contains, "tags", ["featured", "new"])
      
      # Array overlap
      create_array_filter(:array_overlap, "categories", ["electronics", "computers"])
  """
  def create_array_filter(operation, column, value) do
    spec = %Spec{
      id: generate_array_operation_id(operation, column),
      operation: operation,
      column: column,
      value: value,
      validated: false
    }

    validate_array_operation!(spec)
  end

  @doc """
  Create an array length/dimension operation.

  ## Examples

      # Get array length at dimension 1
      create_array_size(:array_length, "tags", 1, as: "tag_count")
      
      # Get array cardinality (total number of elements)
      create_array_size(:cardinality, "matrix", as: "total_elements")
  """
  def create_array_size(operation, column, dimension \\ nil, opts \\ []) do
    spec = %Spec{
      id: generate_array_operation_id(operation, column),
      operation: operation,
      column: column,
      dimension: dimension,
      alias: opts[:as],
      options: Map.new(Keyword.drop(opts, [:as])),
      validated: false
    }

    validate_array_operation!(spec)
  end

  @doc """
  Create an unnest operation for array expansion.

  ## Examples

      # Unnest array column
      create_unnest("special_features", as: "feature")
      
      # Unnest with ordinality
      create_unnest("tags", with_ordinality: true, as: "tag")
  """
  def create_unnest(column, opts \\ []) do
    spec = %Spec{
      id: generate_array_operation_id(:unnest, column),
      operation: :unnest,
      column: column,
      alias: opts[:as],
      options: Map.new(Keyword.drop(opts, [:as])),
      validated: false
    }

    validate_array_operation!(spec)
  end

  @doc """
  Validate an array operation specification.
  """
  def validate_array_operation!(%Spec{validated: true} = spec), do: spec

  def validate_array_operation!(%Spec{} = spec) do
    spec
    |> validate_operation_type!()
    |> validate_column!()
    |> validate_arguments!()
    |> Map.put(:validated, true)
  end

  defp validate_operation_type!(%Spec{operation: op} = spec) when is_atom(op) do
    valid_operations = [
      :array_agg,
      :array_agg_distinct,
      :string_agg,
      :array_contains,
      :array_contained,
      :array_overlap,
      :array_eq,
      :array_length,
      :cardinality,
      :array_ndims,
      :array_dims,
      :array,
      :array_fill,
      :array_append,
      :array_prepend,
      :array_cat,
      :array_position,
      :array_positions,
      :array_remove,
      :array_replace,
      :unnest,
      :array_to_string,
      :string_to_array,
      :array_union,
      :array_intersect,
      :array_except
    ]

    unless op in valid_operations do
      raise ValidationError,
        type: :invalid_operation,
        message: "Invalid array operation: #{inspect(op)}",
        details: %{operation: op, valid_operations: valid_operations}
    end

    spec
  end

  defp validate_column!(%Spec{operation: :array, column: nil} = spec) do
    # ARRAY constructor doesn't need a column
    spec
  end

  defp validate_column!(%Spec{operation: :array_fill, column: nil} = spec) do
    # ARRAY_FILL doesn't need a column
    spec
  end

  defp validate_column!(%Spec{column: nil} = spec) do
    raise ValidationError,
      type: :invalid_column,
      message: "Column is required for array operation",
      details: %{operation: spec.operation}
  end

  defp validate_column!(%Spec{column: column} = spec) when is_binary(column) do
    spec
  end

  defp validate_column!(%Spec{column: column} = spec) when is_tuple(column) do
    # Allow tuples for nested operations like {:array_agg, "column"}
    spec
  end

  defp validate_column!(%Spec{column: column} = _spec) do
    raise ValidationError,
      type: :invalid_column,
      message: "Invalid column type: #{inspect(column)}",
      details: %{column: column}
  end

  defp validate_arguments!(%Spec{operation: op} = spec) when op in [:array_length] do
    unless is_integer(spec.dimension) and spec.dimension > 0 do
      raise ValidationError,
        type: :invalid_dimension,
        message: "Array dimension must be a positive integer for #{op}",
        details: %{operation: op, dimension: spec.dimension}
    end

    spec
  end

  defp validate_arguments!(%Spec{operation: op} = spec)
       when op in [:array_contains, :array_contained, :array_overlap] do
    unless spec.value != nil do
      raise ValidationError,
        type: :invalid_arguments,
        message: "Value is required for #{op} operation",
        details: %{operation: op}
    end

    spec
  end

  defp validate_arguments!(spec), do: spec

  @doc """
  Generate SQL for an array operation.
  """
  def to_sql(%Spec{} = spec, params_list, selecto \\ nil) do
    Selecto.Builder.ArrayOperations.build_array_sql(spec, params_list, selecto)
  end

  @doc """
  Check if an operation is an aggregation function.
  """
  def is_aggregate?(%Spec{operation: op}) do
    op in [:array_agg, :array_agg_distinct, :string_agg]
  end

  @doc """
  Check if an operation is a filter/WHERE clause operation.
  """
  def is_filter?(%Spec{operation: op}) do
    op in [:array_contains, :array_contained, :array_overlap, :array_eq]
  end

  @doc """
  Check if an operation is an unnest operation.
  """
  def is_unnest?(%Spec{operation: :unnest}), do: true
  def is_unnest?(_), do: false

  # Private helpers

  defp generate_array_operation_id(operation, column) when is_binary(column) do
    "array_#{operation}_#{String.replace(column, ".", "_")}_#{:erlang.unique_integer([:positive])}"
  end

  defp generate_array_operation_id(operation, column) do
    "array_#{operation}_#{inspect(column)}_#{:erlang.unique_integer([:positive])}"
  end
end