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 cli.ex
Raw

lib/squirrelix/cli.ex

defmodule Squirrelix.CLI do
@moduledoc false
alias Squirrelix.ConnectionOptions
alias Squirrelix.Project
alias Squirrelix.QueryDirectory
@default_host "localhost"
@default_user "postgres"
@default_database "database"
@default_password ""
@default_port 5432
@default_timeout 5
@type discovered_sql_files :: %{Path.t() => [Path.t()]}
@doc """
Resolves connection options from environment and Mix/CLI opts.
Precedence (highest first): flag overrides → `:url` → `DATABASE_URL` → `PG*` →
defaults.
"""
@spec resolve_connection_options(map(), keyword()) ::
{:ok, ConnectionOptions.t()} | {:error, :invalid_url}
def resolve_connection_options(env, opts \\ []) when is_map(env) and is_list(opts) do
project_name = Keyword.get(opts, :project_name, "postgres")
with {:ok, from_url} <- url_override(env, opts) do
base =
env
|> connection_options_from_variables(project_name)
|> merge_connection_options(from_url)
|> merge_flag_overrides(opts)
{:ok, base}
end
end
@spec parse_connection_url(String.t()) ::
{:ok, ConnectionOptions.t()} | {:error, :invalid_url}
def parse_connection_url(raw) when is_binary(raw) do
with %URI{} = uri <- URI.parse(raw),
:ok <- check_scheme(uri.scheme),
{:ok, timeout, ssl} <- parse_query_options(uri.query) do
{user, password} = parse_user_and_password(uri.userinfo)
database = parse_database(uri.path)
{:ok,
%ConnectionOptions{
host: default_if_nil_or_empty(uri.host, @default_host),
port: uri.port || @default_port,
user: default_if_nil_or_empty(user, @default_user),
password: default_if_nil_or_empty(password, @default_password),
database: default_if_nil_or_empty(database, @default_database),
timeout_seconds: timeout,
ssl: ssl
}}
else
_ -> {:error, :invalid_url}
end
end
@spec connection_options_from_variables(map(), String.t() | nil) :: ConnectionOptions.t()
def connection_options_from_variables(env, project_name) when is_map(env) do
%ConnectionOptions{
host: env_value(env, "PGHOST", @default_host),
user: env_value(env, "PGUSER", @default_user),
password: env_value(env, "PGPASSWORD", @default_password),
database:
env_value(env, "PGDATABASE", project_name)
|> default_if_nil_or_empty(@default_database),
port: env_value(env, "PGPORT", nil) |> parse_int_or_default(@default_port),
timeout_seconds:
env_value(env, "PGCONNECT_TIMEOUT", nil) |> parse_int_or_default(@default_timeout),
ssl: ssl_from_env_mode(env_value(env, "PGSSLMODE", nil))
}
end
@doc """
Recursively finds `sql/` directories and their `.sql` files under `path`.
Returns a map of directory paths to sorted SQL file paths.
"""
@spec discover_sql_directories(Path.t()) :: discovered_sql_files()
def discover_sql_directories(path) when is_binary(path) do
do_discover_sql_directories(path)
end
@spec query_files(Path.t()) :: discovered_sql_files()
def query_files(root) when is_binary(root) do
root
|> Project.source_roots()
|> Enum.map(&discover_sql_directories/1)
|> Enum.reduce(%{}, &Map.merge/2)
end
@spec query_directories(Path.t()) :: [QueryDirectory.t()]
def query_directories(root) when is_binary(root) do
root
|> query_files()
|> QueryDirectory.from_discovered_files()
end
@spec directory_to_output_file(String.t()) :: String.t()
def directory_to_output_file(directory) when is_binary(directory) do
directory
|> Path.dirname()
|> Path.join("sql.ex")
end
defp url_override(env, opts) do
case Keyword.fetch(opts, :url) do
{:ok, url} when is_binary(url) and url != "" ->
parse_connection_url(url)
_ ->
case Map.get(env, "DATABASE_URL") do
url when is_binary(url) and url != "" -> parse_connection_url(url)
_ -> {:ok, nil}
end
end
end
defp merge_connection_options(%ConnectionOptions{} = base, nil), do: base
defp merge_connection_options(%ConnectionOptions{} = base, %ConnectionOptions{} = override) do
%ConnectionOptions{
host: override.host || base.host,
port: override.port || base.port,
user: override.user || base.user,
password: prefer_password(override.password, base.password),
database: override.database || base.database,
timeout_seconds: override.timeout_seconds || base.timeout_seconds,
ssl: prefer_ssl(override.ssl, base.ssl)
}
end
defp merge_flag_overrides(%ConnectionOptions{} = base, opts) do
overrides =
[
host: Keyword.get(opts, :hostname),
port: Keyword.get(opts, :port),
user: Keyword.get(opts, :username),
password: Keyword.get(opts, :password),
database: Keyword.get(opts, :database)
]
|> Enum.reject(fn {_key, value} -> value in [nil, ""] end)
|> Map.new()
struct!(base, overrides)
end
# Empty password from a URL like postgres://user@host/db should not wipe PGPASSWORD.
defp prefer_password("", base), do: base
defp prefer_password(password, _base), do: password
defp prefer_ssl(nil, base), do: base
defp prefer_ssl(ssl, _base), do: ssl
defp do_discover_sql_directories(path) do
if Path.basename(path) == "sql" do
files =
case File.ls(path) do
{:ok, entries} ->
entries
|> Enum.map(&Path.join(path, &1))
|> Enum.filter(&(File.regular?(&1) and Path.extname(&1) == ".sql"))
|> Enum.sort()
{:error, _reason} ->
[]
end
%{path => files}
else
path
|> list_directories()
|> Enum.map(&do_discover_sql_directories/1)
|> Enum.reduce(%{}, &Map.merge/2)
end
end
defp list_directories(path) do
case File.ls(path) do
{:ok, entries} ->
entries
|> Enum.map(&Path.join(path, &1))
|> Enum.filter(&File.dir?/1)
{:error, :enoent} ->
[]
{:error, _reason} ->
raise "couldn't read directory: #{path}"
end
end
defp check_scheme(nil), do: :ok
defp check_scheme("postgres"), do: :ok
defp check_scheme("postgresql"), do: :ok
defp check_scheme(_), do: :error
defp parse_user_and_password(nil), do: {nil, nil}
defp parse_user_and_password(userinfo) do
case String.split(userinfo, ":", parts: 2) do
[user] -> {user, nil}
[user, password] -> {user, password}
_ -> {nil, nil}
end
end
defp parse_database(nil), do: nil
defp parse_database(path) when is_binary(path) do
case String.split(path, "/", trim: true) do
[database | _rest] -> database
[] -> nil
end
end
defp parse_query_options(nil), do: {:ok, @default_timeout, nil}
defp parse_query_options(query) do
params = URI.decode_query(query)
with {:ok, timeout} <- parse_timeout_param(params),
{:ok, ssl} <- parse_ssl_param(params) do
{:ok, timeout, ssl}
end
rescue
_ -> :error
end
defp parse_timeout_param(params) do
case Map.fetch(params, "connect_timeout") do
:error ->
{:ok, @default_timeout}
{:ok, timeout} ->
case Integer.parse(timeout) do
{value, ""} -> {:ok, value}
_ -> :error
end
end
end
defp parse_ssl_param(params) do
cond do
Map.has_key?(params, "sslmode") ->
case ssl_from_mode(Map.get(params, "sslmode")) do
:error -> :error
ssl -> {:ok, ssl}
end
Map.has_key?(params, "ssl") ->
case Map.get(params, "ssl") do
value when value in ["true", "1"] -> {:ok, ssl_from_mode("require")}
value when value in ["false", "0"] -> {:ok, false}
_ -> :error
end
true ->
{:ok, nil}
end
end
# Maps libpq sslmode values to Postgrex `:ssl` options.
# `allow`/`prefer` cannot negotiate fallback in Postgrex; treat as off.
# `require` encrypts without CA verification (common hosted-DB DATABASE_URL shape).
# `verify-ca`/`verify-full` use Postgrex secure defaults (`ssl: true`).
defp ssl_from_mode(nil), do: nil
defp ssl_from_mode(""), do: nil
defp ssl_from_mode("disable"), do: false
defp ssl_from_mode("allow"), do: false
defp ssl_from_mode("prefer"), do: false
defp ssl_from_mode("require"), do: [verify: :verify_none]
defp ssl_from_mode("verify-ca"), do: true
defp ssl_from_mode("verify-full"), do: true
defp ssl_from_mode(_), do: :error
defp ssl_from_env_mode(mode) do
case ssl_from_mode(mode) do
:error -> false
nil -> false
ssl -> ssl
end
end
defp env_value(env, key, default), do: Map.get(env, key, default)
defp parse_int_or_default(nil, default), do: default
defp parse_int_or_default(value, _default) when is_integer(value), do: value
defp parse_int_or_default(value, default) when is_binary(value) do
case Integer.parse(value) do
{int_value, ""} -> int_value
_ -> default
end
end
defp parse_int_or_default(_value, default), do: default
defp default_if_nil_or_empty(nil, default), do: default
defp default_if_nil_or_empty("", default), do: default
defp default_if_nil_or_empty(value, _default), do: value
end