defmodule QuackDB.SQL.Fragment do @moduledoc """ Reusable SQL fragments shared by QuackDB statement builders. This module intentionally builds small, composable iodata fragments rather than introducing another statement-level DSL. Public DML/DDL helpers can reuse these fragments while keeping the number of top-level insert/update APIs small. """ @type table :: atom() | String.t() | {atom() | String.t() | nil, atom() | String.t()} @type column :: atom() | String.t() @type alias_name :: atom() | String.t() @type order_direction :: :asc | :desc @type nulls_order :: :first | :last @type order_expression :: column() | {column(), order_direction()} | {column(), order_direction(), keyword()} @doc "Quotes a table name, optionally with a schema/prefix tuple." @spec table(table()) :: iodata() def table({nil, name}), do: QuackDB.Type.quote_identifier(name) def table({prefix, name}) do [QuackDB.Type.quote_identifier(prefix), ?., QuackDB.Type.quote_identifier(name)] end def table(name), do: QuackDB.Type.quote_identifier(name) @doc "Quotes a table alias." @spec alias_name(alias_name()) :: iodata() def alias_name(name), do: QuackDB.Type.quote_identifier(name) @doc "Quotes a column identifier." @spec column(column()) :: iodata() def column(name), do: QuackDB.Type.quote_identifier(name) @doc "Quotes a qualified column reference such as `source.id`." @spec qualified_column(alias_name(), column()) :: iodata() def qualified_column(table_alias, column) do [alias_name(table_alias), ?., column(column)] end @doc "Renders a comma-separated column list." @spec column_list([column()]) :: iodata() def column_list(columns) when is_list(columns) do columns |> Enum.map(&column/1) |> Enum.intersperse(", ") end @doc "Renders a comma-separated qualified column list." @spec qualified_column_list([column()], alias_name()) :: iodata() def qualified_column_list(columns, table_alias) when is_list(columns) do columns |> Enum.map(&qualified_column(table_alias, &1)) |> Enum.intersperse(", ") end @doc "Renders an optional parenthesized insert column list." @spec insert_columns([column()]) :: iodata() def insert_columns([]), do: [] def insert_columns(columns), do: [" (", column_list(columns), ")"] @doc "Renders `*` for an empty select list, otherwise a column list." @spec select_columns([column()]) :: iodata() def select_columns([]), do: "*" def select_columns(columns), do: column_list(columns) @doc "Renders `RETURNING ...` for a non-empty column list." @spec returning([column()]) :: iodata() def returning([]), do: [] def returning(columns), do: [" RETURNING ", column_list(columns)] @doc "Renders supported `ON CONFLICT` clauses." @spec on_conflict(:raise | :nothing | {:nothing, [column()]}) :: iodata() def on_conflict(:raise), do: [] def on_conflict(:nothing), do: " ON CONFLICT DO NOTHING" def on_conflict({:nothing, targets}) when is_list(targets) do [" ON CONFLICT ", conflict_target(targets), "DO NOTHING"] end @doc "Renders a conflict target such as `(id, name)`, including trailing space." @spec conflict_target([column()]) :: iodata() def conflict_target([]), do: [] def conflict_target(targets), do: ["(", column_list(targets), ") "] @doc "Renders equality between two qualified columns." @spec qualified_equality(alias_name(), column(), alias_name(), column()) :: iodata() def qualified_equality(left_alias, left_column, right_alias, right_column) do [ qualified_column(left_alias, left_column), " = ", qualified_column(right_alias, right_column) ] end @doc "Renders `IS NOT DISTINCT FROM` between two qualified columns." @spec qualified_not_distinct(alias_name(), column(), alias_name(), column()) :: iodata() def qualified_not_distinct(left_alias, left_column, right_alias, right_column) do [ qualified_column(left_alias, left_column), " IS NOT DISTINCT FROM ", qualified_column(right_alias, right_column) ] end @doc "Renders a parenthesized window partition column list." @spec partition_by([column()]) :: iodata() def partition_by(columns) when is_list(columns) and columns != [] do ["PARTITION BY ", column_list(columns)] end def partition_by(other) do raise ArgumentError, "expected at least one partition column, got: #{inspect(other)}" end @doc "Renders an optional `ORDER BY` clause for window expressions." @spec order_by([order_expression()]) :: iodata() def order_by([]), do: [] def order_by(expressions) when is_list(expressions) do [" ORDER BY ", expressions |> Enum.map(&order_expression/1) |> Enum.intersperse(", ")] end @doc "Renders a `row_number() OVER (...) AS alias` expression." @spec row_number_over(keyword()) :: iodata() def row_number_over(options) when is_list(options) do partition_by = Keyword.fetch!(options, :partition_by) order_by = Keyword.get(options, :order_by, []) as = Keyword.get(options, :as, :row_number) [ "row_number() OVER (", partition_by(partition_by), order_by(order_by), ") AS ", column(as) ] end @doc "Renders an optional `WHERE` clause from raw predicate iodata." @spec where(nil | iodata()) :: iodata() def where(nil), do: [] def where(predicate), do: [" WHERE ", predicate] @doc "Renders `expression AS alias`." @spec as(iodata(), column()) :: iodata() def as(expression, alias_name), do: [expression, " AS ", column(alias_name)] @doc "Builds a small `SELECT` query fragment." @spec select([iodata()], keyword()) :: iodata() def select(projections, options \\ []) when is_list(projections) and is_list(options) do [ "SELECT ", distinct(Keyword.get(options, :distinct, false)), projection_list(projections), from(Keyword.get(options, :from)), where(Keyword.get(options, :where)) ] end @doc "Combines queries with `UNION` or `UNION ALL`, with optional ordering." @spec union([iodata()], keyword()) :: iodata() def union(queries, options \\ []) when is_list(queries) and queries != [] do all? = Keyword.get(options, :all, false) operator = if all?, do: " UNION ALL ", else: " UNION " [Enum.intersperse(queries, operator), order_by(Keyword.get(options, :order_by, []))] end @doc "Renders a simple joined table clause." @spec join(:inner | :left, table(), keyword()) :: iodata() def join(kind, joined_table, options) when kind in [:inner, :left] and is_list(options) do [ join_kind(kind), " JOIN ", table(joined_table), join_alias(Keyword.get(options, :as)), " ON ", Keyword.fetch!(options, :on) ] end defp order_expression({column, direction}) when direction in [:asc, :desc] do [column(column), " ", direction |> Atom.to_string() |> String.upcase()] end defp order_expression({column, direction, options}) when direction in [:asc, :desc] and is_list(options) do [order_expression({column, direction}), nulls_order(Keyword.get(options, :nulls))] end defp order_expression(column), do: column(column) defp nulls_order(nil), do: [] defp nulls_order(:first), do: " NULLS FIRST" defp nulls_order(:last), do: " NULLS LAST" defp nulls_order(other) do raise ArgumentError, "expected :nulls to be :first or :last, got: #{inspect(other)}" end defp distinct(true), do: "DISTINCT " defp distinct(false), do: [] defp projection_list([]), do: "*" defp projection_list(projections), do: Enum.intersperse(projections, ", ") defp from(nil), do: [] defp from(source), do: [" FROM ", table(source)] defp join_kind(:inner), do: " INNER" defp join_kind(:left), do: " LEFT" defp join_alias(nil), do: [] defp join_alias(as), do: [" AS ", alias_name(as)] end