defmodule Squirrelix.CodegenSummary do @moduledoc """ Summary of a generated query-module write pass. """ @enforce_keys [:generated_count, :errors, :status] defstruct [:generated_count, :errors, :status] @type t :: %__MODULE__{ generated_count: non_neg_integer(), errors: [{Path.t(), term()}], status: :empty | :ok | :error } end defmodule Squirrelix.CodegenCheckSummary do @moduledoc """ Summary of a generated query-module check pass. """ @enforce_keys [:checked_count, :errors, :status] defstruct [:checked_count, :errors, :status] @type t :: %__MODULE__{ checked_count: non_neg_integer(), errors: [{Path.t(), term()}], status: :empty | :ok | :error } end defmodule Squirrelix.Codegen do @moduledoc """ Generates Elixir modules for typed SQL queries. Produces per-query row `@type` definitions, `@spec`-annotated functions, and runtime encode/decode helpers. See [Writing Queries](writing_queries.html) and [Types](types.html) for conventions and type mapping. """ alias Squirrelix.Output alias Squirrelix.Parameter alias Squirrelix.Project alias Squirrelix.TypedQuery alias Squirrelix.TypedQueryDirectory alias Squirrelix.TypeMapper @spec generate_module(module(), [TypedQuery.t()], keyword()) :: String.t() def generate_module(module, queries, opts \\ []) when is_atom(module) and is_list(queries) do version = Keyword.fetch!(opts, :version) postgrex_module = Keyword.get(opts, :postgrex, Postgrex) source = """ defmodule #{inspect(module)} do @moduledoc \"\"\" This module contains generated query functions. > This module was generated automatically using Squirrelix #{version}. Runtime row decoding uses `column_spec/0` tuples `{name, type, nullable?}` where `type` is an atom such as `:string` or a list wrapper such as `{:list, :integer}`. \"\"\" @type column_spec :: {atom(), atom() | {:list, atom()}, boolean()} #{queries |> Enum.sort_by(& &1.file) |> Enum.map(&function_source(&1, postgrex_module)) |> join_function_sources()}#{runtime_helpers_section(queries)} end """ source |> Code.format_string!() |> IO.iodata_to_binary() |> Kernel.<>("\n") end @spec write_directory(Path.t(), Path.t(), [TypedQuery.t()], keyword()) :: :ok | {:error, :invalid_sql_directory | struct()} def write_directory(root, sql_directory, queries, opts \\ []) when is_binary(root) and is_binary(sql_directory) and is_list(queries) and is_list(opts) do case Project.module_for_sql_directory(root, sql_directory) do {:ok, module} -> content = generate_module(module, queries, opts) output_file = sql_directory |> Path.dirname() |> Path.join("sql.ex") Output.safe_write(output_file, content) {:error, :invalid_sql_directory} -> {:error, :invalid_sql_directory} end end @spec check_directory(Path.t(), Path.t(), [TypedQuery.t()], keyword()) :: :ok | {:error, :invalid_sql_directory | struct()} def check_directory(root, sql_directory, queries, opts \\ []) when is_binary(root) and is_binary(sql_directory) and is_list(queries) and is_list(opts) do case Project.module_for_sql_directory(root, sql_directory) do {:ok, module} -> content = generate_module(module, queries, opts) output_file = sql_directory |> Path.dirname() |> Path.join("sql.ex") Output.check_file(output_file, content) {:error, :invalid_sql_directory} -> {:error, :invalid_sql_directory} end end @spec write_directories(Path.t(), [TypedQueryDirectory.t()], keyword()) :: [ {Path.t(), :ok | {:error, :invalid_sql_directory | struct()}, non_neg_integer()} ] def write_directories(root, directories, opts \\ []) when is_binary(root) and is_list(directories) and is_list(opts) do directories |> Enum.sort_by(& &1.directory) |> Enum.map(fn %TypedQueryDirectory{directory: directory, queries: queries} -> {directory, write_directory(root, directory, queries, opts), length(queries)} end) end @spec check_directories(Path.t(), [TypedQueryDirectory.t()], keyword()) :: [ {Path.t(), :ok | {:error, :invalid_sql_directory | struct()}, non_neg_integer()} ] def check_directories(root, directories, opts \\ []) when is_binary(root) and is_list(directories) and is_list(opts) do directories |> Enum.sort_by(& &1.directory) |> Enum.map(fn %TypedQueryDirectory{directory: directory, queries: queries} -> {directory, check_directory(root, directory, queries, opts), length(queries)} end) end @spec summarize_write_outcomes([{Path.t(), :ok | {:error, term()}, non_neg_integer()}]) :: Squirrelix.CodegenSummary.t() def summarize_write_outcomes(outcomes) when is_list(outcomes) do {generated_count, errors} = Enum.reduce(outcomes, {0, []}, fn {_directory, :ok, query_count}, {generated_count, errors} -> {generated_count + query_count, errors} {directory, {:error, error}, _query_count}, {generated_count, errors} -> {generated_count, [{directory, error} | errors]} end) errors = Enum.reverse(errors) %Squirrelix.CodegenSummary{ generated_count: generated_count, errors: errors, status: summary_status(generated_count, errors) } end @spec summarize_check_outcomes([{Path.t(), :ok | {:error, term()}, non_neg_integer()}]) :: Squirrelix.CodegenCheckSummary.t() def summarize_check_outcomes(outcomes) when is_list(outcomes) do {checked_count, errors} = Enum.reduce(outcomes, {0, []}, fn {_directory, :ok, query_count}, {checked_count, errors} -> {checked_count + query_count, errors} {directory, {:error, error}, _query_count}, {checked_count, errors} -> {checked_count, [{directory, error} | errors]} end) errors = Enum.reverse(errors) %Squirrelix.CodegenCheckSummary{ checked_count: checked_count, errors: errors, status: summary_status(checked_count, errors) } end defp function_source(%TypedQuery{} = query, postgrex_module) do args = TypedQuery.resolve_parameter_names(query.params) all_args = ["conn" | args] encoded_params = encode_params_call(args, query.params) """ #{doc_source(query)} #{row_type_source(query)}@spec #{query.name}(Postgrex.conn()#{spec_args(query.params)}) :: #{function_return_typespec(query)} def #{query.name}(#{Enum.join(all_args, ", ")}) do conn |> #{inspect(postgrex_module)}.query!(#{sql_string_literal(query.content)}, #{encoded_params}) |> #{decode_call(query.returns)} end """ end defp encode_params_call([], []), do: "[]" defp encode_params_call(args, params) do args |> Enum.zip(params) |> Enum.map(fn {name, %Parameter{type: type}} -> "encode_value(#{name}, #{inspect(type, limit: :infinity)})" end) |> then(&"[#{Enum.join(&1, ", ")}]") end @runtime_helpers_header "# --- Runtime helpers ---" defp runtime_helpers_section([]), do: "" defp runtime_helpers_section(queries) do case runtime_helper_sources(queries) do [] -> "" sources -> """ #{@runtime_helpers_header} #{Enum.join(sources, "\n\n")} """ end end defp runtime_helper_sources(queries) do [] |> maybe_add_command_helper(queries) |> maybe_add_rows_helper(queries) |> maybe_add_encode_helpers(queries) |> maybe_add_uuid_helpers(queries) |> Enum.reverse() end defp param_types(queries) do queries |> Enum.flat_map(fn query -> Enum.map(query.params, & &1.type) end) |> MapSet.new() end defp return_types(queries) do queries |> Enum.flat_map(fn query -> Enum.map(query.returns, & &1.type) end) |> MapSet.new() end defp maybe_add_command_helper(sources, queries) do if Enum.any?(queries, &(&1.returns == [])) do [ """ defp decode_command(%Postgrex.Result{}) do :ok end """ | sources ] else sources end end defp maybe_add_rows_helper(sources, queries) do if Enum.any?(queries, &(&1.returns != [])) do types = return_types(queries) [ """ @spec decode_rows(Postgrex.Result.t(), [column_spec()]) :: [map()] defp decode_rows(%Postgrex.Result{rows: rows}, column_specs) do Enum.map(rows, &decode_row(&1, column_specs)) end @spec decode_row(list(), [column_spec()]) :: map() defp decode_row(row, column_specs) do column_specs |> Enum.zip(row) |> Map.new(fn {{name, type, nullable?}, value} -> {name, decode_column_value(value, type, nullable?)} end) end @spec decode_column_value(term(), atom() | {:list, atom()}, boolean()) :: term() defp decode_column_value(value, _type, true) when is_nil(value), do: nil defp decode_column_value(value, type, _nullable?), do: decode_scalar(value, type) #{decode_type_clauses(types)} """ | sources ] else sources end end defp decode_type_clauses(types) do types |> then(fn types -> [] |> add_type_clause(types, :integer, "defp decode_scalar(value, :integer), do: value") |> add_type_clause(types, :string, "defp decode_scalar(value, :string), do: value") |> add_type_clause(types, :boolean, "defp decode_scalar(value, :boolean), do: value") |> add_type_clause(types, :float, "defp decode_scalar(value, :float), do: value") |> add_type_clause(types, :decimal, "defp decode_scalar(value, :decimal), do: value") |> add_type_clause(types, :binary, "defp decode_scalar(value, :binary), do: value") |> add_type_clause(types, :date, "defp decode_scalar(value, :date), do: value") |> add_type_clause(types, :time, "defp decode_scalar(value, :time), do: value") |> add_type_clause( types, :naive_datetime, "defp decode_scalar(value, :naive_datetime), do: value" ) |> add_type_clause( types, :utc_datetime, "defp decode_scalar(value, :utc_datetime), do: value" ) |> add_type_clause( types, :map, """ defp decode_scalar(value, :map) when is_map(value), do: value defp decode_scalar(value, :map) when is_binary(value), do: JSON.decode!(value) """ ) |> add_type_clause( types, :uuid, """ defp decode_scalar(value, :uuid) when is_binary(value) and byte_size(value) == 16, do: uuid_to_string(value) defp decode_scalar(value, :uuid), do: value """ ) |> add_list_type_clauses(types) end) |> Enum.reverse() |> Kernel.++([ "defp decode_scalar(value, _type), do: value" ]) |> Enum.join("\n") end defp add_type_clause(clauses, types, type, source) do if MapSet.member?(types, type) or list_element_type?(types, type) do [source | clauses] else clauses end end defp add_list_type_clauses(clauses, types) do types |> Enum.filter(&match?({:list, _}, &1)) |> Enum.reduce(clauses, fn {:list, type}, clauses -> if Enum.any?(clauses, &String.contains?(&1, "{:list, #{inspect(type)}}")) do clauses else [ "defp decode_scalar(value, {:list, #{inspect(type)}}) when is_list(value), do: Enum.map(value, &decode_scalar(&1, #{inspect(type)}))" | clauses ] end end) end defp maybe_add_encode_helpers(sources, queries) do if Enum.any?(queries, &(&1.params != [])) do types = param_types(queries) [ """ #{encode_type_clauses(types)} """ | sources ] else sources end end defp encode_type_clauses(types) do types |> then(fn types -> [] |> add_type_clause(types, :integer, "defp encode_value(value, :integer), do: value") |> add_type_clause(types, :string, "defp encode_value(value, :string), do: value") |> add_type_clause(types, :boolean, "defp encode_value(value, :boolean), do: value") |> add_type_clause(types, :float, "defp encode_value(value, :float), do: value") |> add_type_clause(types, :decimal, "defp encode_value(value, :decimal), do: value") |> add_type_clause(types, :binary, "defp encode_value(value, :binary), do: value") |> add_type_clause(types, :date, "defp encode_value(value, :date), do: value") |> add_type_clause(types, :time, "defp encode_value(value, :time), do: value") |> add_type_clause( types, :naive_datetime, "defp encode_value(value, :naive_datetime), do: value" ) |> add_type_clause( types, :utc_datetime, "defp encode_value(value, :utc_datetime), do: value" ) |> add_type_clause( types, :map, "defp encode_value(value, :map), do: JSON.encode!(value)" ) |> add_type_clause( types, :uuid, "defp encode_value(value, :uuid), do: uuid_from_string(value)" ) |> add_list_encode_clauses(types) end) |> Enum.reverse() |> Enum.join("\n") end defp add_list_encode_clauses(clauses, types) do types |> Enum.filter(&match?({:list, _}, &1)) |> Enum.reduce(clauses, fn {:list, type}, clauses -> if Enum.any?(clauses, &String.contains?(&1, "{:list, #{inspect(type)}}")) do clauses else [ "defp encode_value(value, {:list, #{inspect(type)}}) when is_list(value), do: Enum.map(value, &encode_value(&1, #{inspect(type)}))" | clauses ] end end) end defp maybe_add_uuid_helpers(sources, queries) do param_type_set = param_types(queries) return_type_set = return_types(queries) encode_uuid? = MapSet.member?(param_type_set, :uuid) or list_element_type?(param_type_set, :uuid) decode_uuid? = MapSet.member?(return_type_set, :uuid) or list_element_type?(return_type_set, :uuid) case {encode_uuid?, decode_uuid?} do {false, false} -> sources {encode_uuid?, decode_uuid?} -> [uuid_helper_source(encode_uuid?, decode_uuid?) | sources] end end defp uuid_helper_source(true, true) do """ defp uuid_to_string(uuid) when is_binary(uuid) and byte_size(uuid) == 16 do hex = Base.encode16(uuid, case: :lower) <> = hex "\#{part1}-\#{part2}-\#{part3}-\#{part4}-\#{part5}" end defp uuid_from_string(string) when is_binary(string) do case Base.decode16(String.replace(string, "-", ""), case: :mixed) do {:ok, <<_::128>> = uuid} -> uuid _ -> raise ArgumentError, "invalid UUID: \#{inspect(string)}" end end """ end defp uuid_helper_source(true, false) do """ defp uuid_from_string(string) when is_binary(string) do case Base.decode16(String.replace(string, "-", ""), case: :mixed) do {:ok, <<_::128>> = uuid} -> uuid _ -> raise ArgumentError, "invalid UUID: \#{inspect(string)}" end end """ end defp uuid_helper_source(false, true) do """ defp uuid_to_string(uuid) when is_binary(uuid) and byte_size(uuid) == 16 do hex = Base.encode16(uuid, case: :lower) <> = hex "\#{part1}-\#{part2}-\#{part3}-\#{part4}-\#{part5}" end """ end defp list_element_type?(types, element_type) do Enum.any?(types, fn {:list, ^element_type} -> true _ -> false end) end defp decode_call([]), do: "decode_command()" defp decode_call(columns) do "decode_rows(#{column_specs_literal(columns)})" end defp column_specs_literal(columns) do columns |> Enum.map_join(", ", fn column -> "{#{atom_literal(column.name)}, #{inspect(column.type, limit: :infinity)}, #{inspect(column.nullable?)}}" end) |> then(&"[#{&1}]") end # Validated snake_case identifiers — emit `:name` without creating Mix VM atoms. defp atom_literal(name) when is_binary(name), do: ":#{name}" defp join_function_sources([]), do: "" defp join_function_sources(sources) do Enum.map_join(sources, "\n\n", &String.trim/1) end defp doc_source(%TypedQuery{comment: [], returns: [], params: params} = query) when length(params) >= 8 do generated_function_doc(query) end defp doc_source(%TypedQuery{comment: []}), do: "" defp doc_source(%TypedQuery{comment: comments}) do " @doc #{inspect(Enum.join(comments, "\n"), limit: :infinity)}\n" end defp spec_args([]), do: "" defp spec_args(params) do params |> Enum.map(&TypeMapper.typespec(&1.type)) |> Enum.map_join("", &", #{&1}") end defp row_type_source(%TypedQuery{returns: []}), do: "" defp row_type_source(%TypedQuery{name: name, returns: returns}) do fields = Enum.map_join(returns, ", ", fn column -> spec = if column.nullable? do "#{TypeMapper.typespec(column.type)} | nil" else TypeMapper.typespec(column.type) end "required(#{atom_literal(column.name)}) => #{spec}" end) """ @type #{name}_row :: %{#{fields}} """ end defp function_return_typespec(%TypedQuery{returns: []}), do: ":ok" defp function_return_typespec(%TypedQuery{name: name, returns: _returns}) do "[#{name}_row()]" end defp generated_function_doc(%TypedQuery{name: name, file: file}) do doc = "Runs the `#{name}` query defined in `#{Path.basename(file)}`." " @doc #{inspect(doc, limit: :infinity)}\n" end # Use inspect/2 so SQL never becomes live Elixir interpolation (`#{}`) in # generated modules. defp sql_string_literal(content), do: inspect(content, limit: :infinity) defp summary_status(0, []), do: :empty defp summary_status(_generated_count, []), do: :ok defp summary_status(_generated_count, [_ | _]), do: :error end