Current section
Files
Jump to
Current section
Files
lib/selecto/builder/array_operations.ex
defmodule Selecto.Builder.ArrayOperations do
@moduledoc """
SQL builder for PostgreSQL array operations.
Generates SQL for array aggregation, manipulation, testing, and unnest operations.
Handles proper parameter binding and escaping for safe SQL generation.
"""
alias Selecto.Advanced.ArrayOperations.Spec
import Selecto.Builder.Sql.Helpers
# Helper to build properly quoted column references
defp build_column_reference(column, selecto) when is_binary(column) do
# Parse qualified column name (e.g., "selecto_root.field" or "field")
case String.split(column, ".", parts: 2) do
[table, field] when selecto != nil ->
# Qualified column with selecto context - quote both parts
quote_char = get_quote_char(selecto)
"#{quote_char}#{table}#{quote_char}.#{quote_char}#{field}#{quote_char}"
[field] when selecto != nil ->
# Unqualified field with selecto context - add source table alias with quotes
quote_char = get_quote_char(selecto)
"#{quote_char}selecto_root#{quote_char}.#{quote_char}#{field}#{quote_char}"
[table, field] ->
# Qualified column without selecto (unit test) - use as-is
"#{table}.#{field}"
[field] ->
# Unqualified field without selecto (unit test) - use as-is
field
end
end
defp build_column_reference(column, selecto) when is_atom(column) do
build_column_reference(Atom.to_string(column), selecto)
end
defp build_column_reference({:field, field}, selecto) do
build_column_reference(field, selecto)
end
# Handle nested array operations (e.g., {:array_agg, "film_id"})
defp build_column_reference({op, column}, selecto) when is_atom(op) do
# This is a nested array operation - build it recursively
spec = %Spec{
id: "nested_#{op}",
operation: op,
column: column,
validated: true
}
{iodata, _params} = build_array_sql(spec, [], selecto)
# Convert iodata to string for embedding
IO.iodata_to_binary(iodata)
end
defp build_column_reference(column, _selecto) do
# Fallback for other types
to_string(column)
end
@doc """
Build SQL for an array operation.
Returns iodata with parameter markers instead of SQL strings.
"""
def build_array_sql(%Spec{} = spec, params_list, selecto \\ nil) do
case spec.operation do
# Aggregation operations
op when op in [:array_agg, :array_agg_distinct] ->
build_array_agg_sql(spec, params_list, selecto)
:string_agg ->
build_string_agg_sql(spec, params_list, selecto)
# Testing operations
op when op in [:array_contains, :array_contained, :array_overlap, :array_eq] ->
build_array_test_sql(spec, params_list, selecto)
# Size operations
op when op in [:array_length, :cardinality, :array_ndims, :array_dims] ->
build_array_size_sql(spec, params_list, selecto)
# Construction operations
op when op in [:array, :array_fill, :array_append, :array_prepend, :array_cat] ->
build_array_construct_sql(spec, params_list, selecto)
# Element operations
op when op in [:array_position, :array_positions, :array_remove, :array_replace] ->
build_array_element_sql(spec, params_list, selecto)
# Transformation operations
:unnest ->
build_unnest_sql(spec, params_list, selecto)
op when op in [:array_to_string, :string_to_array] ->
build_array_transform_sql(spec, params_list, selecto)
# Set operations
op when op in [:array_union, :array_intersect, :array_except] ->
build_array_set_sql(spec, params_list, selecto)
_ ->
raise "Unsupported array operation: #{spec.operation}"
end
end
# Array aggregation operations
defp build_array_agg_sql(%Spec{operation: op} = spec, params_list, selecto) do
distinct = if op == :array_agg_distinct or spec.distinct, do: "DISTINCT ", else: ""
column_sql = build_column_reference(spec.column, selecto)
# Build ORDER BY clause if present
order_clause =
if spec.order_by do
order_parts =
Enum.map(spec.order_by, fn
{col, dir} ->
col_ref = build_column_reference(col, selecto)
"#{col_ref} #{String.upcase(to_string(dir))}"
col ->
build_column_reference(col, selecto)
end)
" ORDER BY #{Enum.join(order_parts, ", ")}"
else
""
end
iodata = ["ARRAY_AGG(", distinct, column_sql, order_clause, ")"]
# Add alias if present
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
{iodata, params_list}
end
defp build_string_agg_sql(%Spec{} = spec, params_list, selecto) do
column_sql = build_column_reference(spec.column, selecto)
# Get delimiter from options or use default
delimiter = spec.options[:delimiter] || ","
# Build ORDER BY clause if present
order_clause =
if spec.order_by do
order_parts =
Enum.map(spec.order_by, fn
{col, dir} ->
col_ref = build_column_reference(col, selecto)
"#{col_ref} #{String.upcase(to_string(dir))}"
col ->
build_column_reference(col, selecto)
end)
" ORDER BY #{Enum.join(order_parts, ", ")}"
else
""
end
iodata = ["STRING_AGG(", column_sql, ", ", {:param, delimiter}, order_clause, ")"]
# Add alias if present
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
# Return with parameters appended
{iodata, params_list ++ [delimiter]}
end
# Array testing operations
defp build_array_test_sql(%Spec{} = spec, params_list, selecto) do
column_sql = build_column_reference(spec.column, selecto)
operator =
case spec.operation do
:array_contains -> "@>"
:array_contained -> "<@"
:array_overlap -> "&&"
:array_eq -> "="
end
iodata = [column_sql, " ", operator, " ", {:param, spec.value}]
{iodata, params_list ++ [spec.value]}
end
# Array size operations
defp build_array_size_sql(%Spec{operation: :array_length} = spec, params_list, selecto) do
column_sql = build_column_reference(spec.column, selecto)
iodata = ["ARRAY_LENGTH(", column_sql, ", ", Integer.to_string(spec.dimension), ")"]
# Add alias if present
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
{iodata, params_list}
end
defp build_array_size_sql(%Spec{operation: :cardinality} = spec, params_list, selecto) do
column_sql = build_column_reference(spec.column, selecto)
iodata = ["CARDINALITY(", column_sql, ")"]
# Add alias if present
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
{iodata, params_list}
end
defp build_array_size_sql(%Spec{operation: op} = spec, params_list, selecto)
when op in [:array_ndims, :array_dims] do
column_sql = build_column_reference(spec.column, selecto)
func_name = String.upcase(to_string(op))
iodata = [func_name, "(", column_sql, ")"]
# Add alias if present
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
{iodata, params_list}
end
# Array construction operations
defp build_array_construct_sql(%Spec{operation: :array} = spec, params_list, _selecto) do
# spec.value should contain the array elements
elements = spec.value || []
element_parts = elements |> Enum.map(fn elem -> {:param, elem} end) |> Enum.intersperse(", ")
iodata = ["ARRAY["] ++ element_parts ++ ["]"]
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
{iodata, params_list ++ elements}
end
defp build_array_construct_sql(%Spec{operation: :array_append} = spec, params_list, selecto) do
column_sql = build_column_reference(spec.column, selecto)
iodata = ["ARRAY_APPEND(", column_sql, ", ", {:param, spec.value}, ")"]
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
{iodata, params_list ++ [spec.value]}
end
defp build_array_construct_sql(%Spec{operation: :array_prepend} = spec, params_list, selecto) do
column_sql = build_column_reference(spec.column, selecto)
iodata = ["ARRAY_PREPEND(", {:param, spec.value}, ", ", column_sql, ")"]
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
{iodata, params_list ++ [spec.value]}
end
defp build_array_construct_sql(%Spec{operation: :array_cat} = spec, params_list, selecto) do
column_sql = build_column_reference(spec.column, selecto)
iodata = ["ARRAY_CAT(", column_sql, ", ", {:param, spec.value}, ")"]
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
{iodata, params_list ++ [spec.value]}
end
defp build_array_construct_sql(%Spec{operation: :array_fill} = spec, params_list, _selecto) do
dims = spec.options[:dimensions]
iodata = ["ARRAY_FILL(", {:param, spec.value}, ", ", {:param, dims}, ")"]
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
{iodata, params_list ++ [spec.value, dims]}
end
# Array element operations
defp build_array_element_sql(%Spec{operation: op} = spec, params_list, selecto)
when op in [:array_position, :array_positions] do
column_sql = build_column_reference(spec.column, selecto)
func_name = String.upcase(to_string(op))
iodata = [func_name, "(", column_sql, ", ", {:param, spec.value}, ")"]
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
{iodata, params_list ++ [spec.value]}
end
defp build_array_element_sql(%Spec{operation: :array_remove} = spec, params_list, selecto) do
column_sql = build_column_reference(spec.column, selecto)
iodata = ["ARRAY_REMOVE(", column_sql, ", ", {:param, spec.value}, ")"]
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
{iodata, params_list ++ [spec.value]}
end
defp build_array_element_sql(%Spec{operation: :array_replace} = spec, params_list, selecto) do
column_sql = build_column_reference(spec.column, selecto)
new_value = spec.options[:new_value]
iodata = [
"ARRAY_REPLACE(",
column_sql,
", ",
{:param, spec.value},
", ",
{:param, new_value},
")"
]
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
{iodata, params_list ++ [spec.value, new_value]}
end
# Unnest operation
defp build_unnest_sql(%Spec{operation: :unnest} = spec, params_list, selecto) do
column_sql = build_column_reference(spec.column, selecto)
iodata =
if spec.options[:with_ordinality] do
["UNNEST(", column_sql, ") WITH ORDINALITY"]
else
["UNNEST(", column_sql, ")"]
end
# Add alias if present
iodata =
if spec.alias do
if spec.options[:with_ordinality] do
iodata ++ [" AS ", spec.alias, "(value, ordinality)"]
else
iodata ++ [" AS ", spec.alias]
end
else
iodata
end
{iodata, params_list}
end
# Array transformation operations
defp build_array_transform_sql(%Spec{operation: :array_to_string} = spec, params_list, selecto) do
column_sql = build_column_reference(spec.column, selecto)
delimiter = spec.value || ","
iodata =
if spec.options[:null_string] do
null_string = spec.options[:null_string]
[
"ARRAY_TO_STRING(",
column_sql,
", ",
{:param, delimiter},
", ",
{:param, null_string},
")"
]
else
["ARRAY_TO_STRING(", column_sql, ", ", {:param, delimiter}, ")"]
end
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
params =
if spec.options[:null_string] do
params_list ++ [delimiter, spec.options[:null_string]]
else
params_list ++ [delimiter]
end
{iodata, params}
end
defp build_array_transform_sql(%Spec{operation: :string_to_array} = spec, params_list, selecto) do
column_sql = build_column_reference(spec.column, selecto)
delimiter = spec.value || ","
iodata =
if spec.options[:null_string] do
null_string = spec.options[:null_string]
[
"STRING_TO_ARRAY(",
column_sql,
", ",
{:param, delimiter},
", ",
{:param, null_string},
")"
]
else
["STRING_TO_ARRAY(", column_sql, ", ", {:param, delimiter}, ")"]
end
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
params =
if spec.options[:null_string] do
params_list ++ [delimiter, spec.options[:null_string]]
else
params_list ++ [delimiter]
end
{iodata, params}
end
# Array set operations (PostgreSQL 14+)
defp build_array_set_sql(%Spec{operation: op} = spec, params_list, selecto) do
column_sql = build_column_reference(spec.column, selecto)
func_name =
case op do
:array_union -> "ARRAY_UNION"
:array_intersect -> "ARRAY_INTERSECT"
:array_except -> "ARRAY_EXCEPT"
end
iodata = [func_name, "(", column_sql, ", ", {:param, spec.value}, ")"]
iodata = if spec.alias, do: iodata ++ [" AS ", spec.alias], else: iodata
{iodata, params_list ++ [spec.value]}
end
end