Packages

Generates typed Elixir query modules from plain SQL files using Postgres inference or static metadata.

Current section

Files

Jump to
squirr_elix lib squirrelix mix_task.ex
Raw

lib/squirrelix/mix_task.ex

defmodule Squirrelix.MixTask do
@moduledoc false
alias Squirrelix.CLI
alias Squirrelix.CodegenCheckSummary
alias Squirrelix.CodegenSummary
alias Squirrelix.Error
alias Squirrelix.Inference
alias Squirrelix.Metadata
alias Squirrelix.Postgres
alias Squirrelix.Watch
@switches [
metadata: :string,
write_metadata: :string,
infer: :boolean,
watch: :boolean,
url: :string,
database: :string,
hostname: :string,
username: :string,
password: :string,
port: :integer
]
@spec generate([String.t()]) :: :ok
def generate(args) do
root = File.cwd!()
opts = parse_args!(args)
if opts[:watch] do
watch_generate!(root, opts)
else
generate_once!(root, opts)
end
end
@spec check([String.t()]) :: :ok
def check(args) do
root = File.cwd!()
opts = parse_args!(args)
if opts[:watch] do
Mix.raise("--watch is only supported by mix squirrelix.gen")
end
with_query_source!(opts, root, fn query_source ->
root
|> Squirrelix.check(query_source, version: version())
|> report_check_summary()
end)
end
defp watch_generate!(root, opts) do
generate_once!(root, opts)
dirs = Watch.watchable_dirs(root)
Mix.shell().info(
"Watching for .sql changes under {lib,test,dev}/**/sql/. Press Ctrl-C to stop."
)
debounce_ms = Application.get_env(:squirr_elix, :watch_debounce_ms, 200)
after_start =
case Application.get_env(:squirr_elix, :watch_test_hook) do
fun when is_function(fun, 1) -> fun
_ -> nil
end
Watch.watch!(
root: root,
dirs: dirs,
debounce_ms: debounce_ms,
after_start: after_start,
on_change: fn -> soft_generate_once(root, opts) end
)
end
defp generate_once!(root, opts) do
with_query_source!(opts, root, fn query_source ->
root
|> Squirrelix.generate(query_source, version: version())
|> report_generate_summary()
end)
end
defp soft_generate_once(root, opts) do
generate_once!(root, opts)
rescue
error in Mix.Error ->
Mix.shell().error(Exception.message(error))
:error
end
defp with_query_source!(opts, root, callback) do
write_metadata? = is_binary(opts[:write_metadata])
infer? = opts[:infer] == true
cond do
write_metadata? and not infer? ->
Mix.raise("--write-metadata requires --infer")
infer? and write_metadata? ->
with_infer_and_optional_export!(opts, root, callback)
infer? ->
with_postgres_inferrer!(opts, callback)
true ->
callback.(load_metadata!(opts, root))
end
end
defp with_infer_and_optional_export!(opts, root, callback) do
with_postgres_inferrer!(opts, &maybe_export_then_callback(opts, root, &1, callback))
end
defp maybe_export_then_callback(opts, root, inferrer, callback) do
case exportable_metadata(root, inferrer) do
{:ok, metadata} ->
write_metadata!(opts[:write_metadata], metadata, root)
callback.(metadata)
:has_errors ->
callback.(inferrer)
end
end
defp exportable_metadata(root, inferrer) do
case CLI.query_directories(root) do
{:error, error} ->
Mix.raise("Could not discover SQL directories:\n\n#{Error.format(error)}")
{:ok, query_directories} ->
directories = Inference.from_query_directories(query_directories, inferrer)
if Enum.any?(directories, &(&1.errors != [])) do
:has_errors
else
{:ok, Metadata.from_typed_directories(directories)}
end
end
end
defp write_metadata!(path, metadata, root) do
file = Path.expand(path, root)
case Metadata.to_file(file, metadata, root: root) do
:ok ->
display = Path.relative_to(file, root)
Mix.shell().info("Wrote metadata to #{display}.")
:ok
{:error, error} ->
Mix.raise("Could not write Squirrelix metadata:\n\n#{Error.format(error)}")
end
end
defp with_postgres_inferrer!(opts, callback) do
{:ok, _} = Application.ensure_all_started(:postgrex)
connection_options = build_connection_options(opts)
case Postgres.connect(connection_options) do
{:ok, conn} ->
try do
callback.(Postgres.inferrer(conn))
after
GenServer.stop(conn)
end
{:error, error} ->
Mix.raise("Squirrelix connection failed:\n\n#{Error.format(error)}")
end
end
# Precedence (highest first): flags → --url → DATABASE_URL → PG* → defaults.
defp build_connection_options(opts) do
case CLI.resolve_connection_options(System.get_env(), opts) do
{:ok, connection_options} ->
connection_options
{:error, :invalid_url} ->
Mix.raise("""
Invalid Postgres connection URL
Hint: Use a Postgres URL such as `postgres://username:password@host:port/database_name` (or `postgresql://...`).
""")
end
end
defp load_metadata!(opts, root) do
metadata_file = opts |> Keyword.get(:metadata, "squirr_elix.exs") |> Path.expand(root)
case Metadata.from_file(metadata_file, root: root) do
{:ok, metadata} ->
metadata
{:error, error} ->
Mix.raise("Could not load Squirrelix metadata:\n\n#{Error.format(error)}")
end
end
defp parse_args!(args) do
case OptionParser.parse(args, strict: @switches) do
{opts, [], []} -> opts
{_opts, extra, []} -> Mix.raise("Unexpected arguments: #{Enum.join(extra, " ")}")
{_opts, _extra, invalid} -> Mix.raise("Invalid options: #{format_invalid_options(invalid)}")
end
end
defp format_invalid_options(invalid) do
Enum.map_join(invalid, ", ", fn
{option, nil} -> option
{option, value} -> "#{option}=#{value}"
end)
end
defp report_generate_summary(%CodegenSummary{status: :ok, generated_count: count}) do
Mix.shell().info("Generated #{count} #{pluralize(count, "query", "queries")}.")
:ok
end
defp report_generate_summary(%CodegenSummary{status: :empty}) do
Mix.shell().info("No SQL queries found.")
:ok
end
defp report_generate_summary(%CodegenSummary{errors: errors}) do
Mix.raise("Squirrelix generation failed:\n\n#{format_codegen_errors(errors)}")
end
defp report_check_summary(%CodegenCheckSummary{status: :ok, checked_count: count}) do
Mix.shell().info("All #{count} #{pluralize(count, "query", "queries")} current.")
:ok
end
defp report_check_summary(%CodegenCheckSummary{status: :empty}) do
Mix.shell().info("No SQL queries found.")
:ok
end
defp report_check_summary(%CodegenCheckSummary{errors: errors}) do
Mix.raise("Squirrelix check failed:\n\n#{format_codegen_errors(errors)}")
end
defp format_codegen_errors(errors) do
Enum.map_join(errors, "\n\n", fn {directory, error} ->
"#{directory}\n#{format_directory_error(error)}"
end)
end
defp format_directory_error(errors) when is_list(errors), do: Error.format_all(errors)
defp format_directory_error(%_{} = error), do: Error.format(error)
defp format_directory_error(other), do: inspect(other)
defp pluralize(1, singular, _plural), do: singular
defp pluralize(_count, _singular, plural), do: plural
defp version do
Application.spec(:squirr_elix, :vsn)
|> to_string()
end
end