defmodule Squirrelix do @moduledoc """ Elixir-native SQL query code generation for Mix projects. Squirrelix discovers `.sql` files under conventional Elixir source roots (`lib/`, `test/`, `dev/`), then generates sibling `sql.ex` modules with `@spec`-annotated functions that execute through Postgrex. ## Supported public API Pre-1.0, the **supported** programmatic surface is intentionally small: * `generate/3` / `check/3` — primary entry points * Mix tasks `mix squirrelix.gen` and `mix squirrelix.check` * Return summaries `Squirrelix.CodegenSummary` / `Squirrelix.CodegenCheckSummary` * Documented `Squirrelix.Error.*` structs (and `Squirrelix.Error.format/1`) * `Squirrelix.Postgres.inferrer/1` and `Squirrelix.Inference.Inferrer` (plus `Squirrelix.Query` as the Inferrer callback argument) Other modules under `Squirrelix.*` are **internal** (`@moduledoc false`) and may change without notice. See [Configuration](configuration.html#supported-public-api) for the full inventory. ## Guides * [Getting Started](getting_started.html) — installation, layout, first query * [Writing Queries](writing_queries.html) — one query per file, comments, naming * [Types](types.html) — Postgres to Elixir type mapping * [Configuration](configuration.html) — inference, metadata, connection, CI See also `README.md` for a full overview. ## Query sources Generation and checking accept a *query source* in one of two forms: * A metadata map — keys are query file paths, values are keyword lists with `:params` and `:returns`. Load from `squirr_elix.exs` or pass a map to `generate/3` and `check/3`. * An inferrer — a `(query -> {:ok, keyword()} | {:error, struct()})` function or a module implementing `Squirrelix.Inference.Inferrer`. The Mix task uses this mode with `--infer` to ask Postgres for column and parameter types. Generated code uses stdlib types in `@spec` output: `String.t()` for Postgres enums, `term()` for JSON/JSONB columns, `map()` with `required/1` for row shapes, and plain maps at runtime rather than Gleam records or custom enum ADTs. """ # Marker written into generated `@moduledoc`. Classification only trusts this # near the top of the file so a buried mention cannot spoof overwrite. @generated_module_marker "> 🐿️ This module was generated automatically using" @generated_module_marker_ascii "> This module was generated automatically using" @generated_header_bytes 2_048 @type metadata :: %{Path.t() => keyword()} @type query_source :: metadata() | Squirrelix.Inference.inferrer() # Internal: used by safe overwrite / drift detection. Not part of the supported API. @doc false @spec classify_file_content(String.t()) :: :likely_generated | :empty | :not_generated def classify_file_content(content) when is_binary(content) do header = binary_prefix(content, @generated_header_bytes) likely_generated? = String.contains?(header, @generated_module_marker) or String.contains?(header, @generated_module_marker_ascii) cond do likely_generated? -> :likely_generated String.trim(content) == "" -> :empty true -> :not_generated end end defp binary_prefix(content, max_bytes) when byte_size(content) <= max_bytes, do: content defp binary_prefix(content, max_bytes) do binary_part(content, 0, max_bytes) end # Internal: used by drift detection. Not part of the supported API. @doc false @spec compare_code_snippets(String.t(), String.t()) :: :same | :different def compare_code_snippets(actual_code, expected_code) when is_binary(actual_code) and is_binary(expected_code) do if tokenize(actual_code) == tokenize(expected_code) do :same else :different end end @doc """ Generates query modules for SQL files discovered under a Mix project root. Generation is **project-wide atomic**: if any `sql/` directory has query errors (invalid names, missing metadata, inference failures, …), nothing is written — including directories that would otherwise succeed. Fix every error, then re-run. ## Options * `:version` (required) — generator version string written into file headers * `:postgrex` — module passed to generated code (defaults to `Postgrex`) Returns a `Squirrelix.CodegenSummary` struct summarizing writes and errors. """ @spec generate(Path.t(), query_source(), keyword()) :: Squirrelix.CodegenSummary.t() def generate(root, query_source, opts \\ []) when is_binary(root) and is_list(opts) do directories = typed_query_directories(root, query_source) case directory_query_errors(directories) do [] -> directories |> Enum.map(&write_typed_directory(root, &1, opts)) |> Squirrelix.Codegen.summarize_write_outcomes() errors -> # Gleam squirrel 4.5+ parity: refuse all writes when any query errors exist. Squirrelix.Codegen.summarize_write_outcomes(errors) end end @doc """ Checks that generated query modules are current without writing files. Accepts the same `query_source` and options as `generate/3`. Fails globally when any directory has query errors or drift — the summary status is `:error` and every failing directory is included in `:errors`. Returns a `Squirrelix.CodegenCheckSummary` struct. """ @spec check(Path.t(), query_source(), keyword()) :: Squirrelix.CodegenCheckSummary.t() def check(root, query_source, opts \\ []) when is_binary(root) and is_list(opts) do root |> typed_query_directories(query_source) |> Enum.map(&check_typed_directory(root, &1, opts)) |> Squirrelix.Codegen.summarize_check_outcomes() end @spec tokenize(String.t()) :: [String.t()] defp tokenize(code) do chars = String.to_charlist(code) chars |> do_tokenize(:normal, [], []) |> Enum.reverse() end defp do_tokenize([], :normal, current, tokens), do: maybe_push(current, tokens) defp do_tokenize([], :line_comment, current, tokens), do: maybe_push(current, tokens) defp do_tokenize([], :string, current, tokens), do: maybe_push(current, tokens) defp do_tokenize([?/, ?/ | rest], :normal, current, tokens) do tokens = maybe_push(current, tokens) do_tokenize(rest, :line_comment, [], tokens) end defp do_tokenize([?# | rest], :normal, current, tokens) do tokens = maybe_push(current, tokens) do_tokenize(rest, :line_comment, [], tokens) end defp do_tokenize([char | rest], :line_comment, current, tokens) do if char == ?\n do do_tokenize(rest, :normal, current, tokens) else do_tokenize(rest, :line_comment, current, tokens) end end defp do_tokenize([?" | rest], :normal, current, tokens) do tokens = maybe_push(current, tokens) do_tokenize(rest, :string, [?"], tokens) end defp do_tokenize([?\\, escaped | rest], :string, current, tokens) do do_tokenize(rest, :string, [escaped, ?\\ | current], tokens) end defp do_tokenize([?" | rest], :string, current, tokens) do token = [?" | Enum.reverse(current)] do_tokenize(rest, :normal, [], [List.to_string(token) | tokens]) end defp do_tokenize([char | rest], :string, current, tokens) do do_tokenize(rest, :string, [char | current], tokens) end defp do_tokenize([char | rest], :normal, current, tokens) when char in [32, 9, 10, 13] do tokens = maybe_push(current, tokens) do_tokenize(rest, :normal, [], tokens) end defp do_tokenize([char | rest], :normal, current, tokens) do char_string = <> if identifier_char?(char) do do_tokenize(rest, :normal, [char | current], tokens) else tokens = maybe_push(current, tokens) do_tokenize(rest, :normal, [], [char_string | tokens]) end end defp maybe_push([], tokens), do: tokens defp maybe_push(current, tokens) do [current |> Enum.reverse() |> List.to_string() | tokens] end defp identifier_char?(char) do (char >= ?a and char <= ?z) or (char >= ?A and char <= ?Z) or (char >= ?0 and char <= ?9) or char in [?_] end defp typed_query_directories(root, metadata) when is_map(metadata) do root |> Squirrelix.CLI.query_directories() |> Enum.map(&Squirrelix.TypedQueryDirectory.from_query_directory(&1, metadata)) |> Enum.sort_by(& &1.directory) end defp typed_query_directories(root, inferrer) when is_function(inferrer, 1) or is_atom(inferrer) do root |> Squirrelix.CLI.query_directories() |> Squirrelix.Inference.from_query_directories(inferrer) end defp directory_query_errors(directories) do directories |> Enum.filter(fn %Squirrelix.TypedQueryDirectory{errors: errors} -> errors != [] end) |> Enum.map(fn %Squirrelix.TypedQueryDirectory{} = directory -> {directory.directory, {:error, directory.errors}, length(directory.queries)} end) end defp write_typed_directory(root, %Squirrelix.TypedQueryDirectory{} = directory, opts) do outcome = Squirrelix.Codegen.write_directory(root, directory.directory, directory.queries, opts) {directory.directory, outcome, length(directory.queries)} end defp check_typed_directory( root, %Squirrelix.TypedQueryDirectory{errors: []} = directory, opts ) do outcome = Squirrelix.Codegen.check_directory(root, directory.directory, directory.queries, opts) {directory.directory, outcome, length(directory.queries)} end defp check_typed_directory(_root, %Squirrelix.TypedQueryDirectory{} = directory, _opts) do {directory.directory, {:error, directory.errors}, length(directory.queries)} end end