defmodule SqlParserEx do @moduledoc """ Elixir NIF wrapper for the `sqlparser` Rust crate. Parses SQL into an AST (as a decoded map) and reconstructs SQL from an AST. ## Dialects Pass a dialect atom as the `dialect:` option. Defaults to `:generic`. ## Limitations `to_sql/2` accepts a `dialect:` option for API consistency, but the underlying Rust serializer uses a dialect-agnostic Display implementation. The dialect argument is validated but does not affect the reconstructed SQL output. ## Examples iex> {:ok, ast} = SqlParserEx.parse("SELECT 1") iex> is_map(ast) and map_size(ast) > 0 true """ alias SqlParserEx.Native @valid_dialects ~w( generic ansi postgres mysql sqlite mssql bigquery clickhouse duckdb databricks hive redshift snowflake )a @type dialect :: :generic | :ansi | :postgres | :mysql | :sqlite | :mssql | :bigquery | :clickhouse | :duckdb | :databricks | :hive | :redshift | :snowflake @type sql_opt :: {:dialect, dialect()} @type sql_string :: String.t() @type parse_error :: String.t() | {:unknown_dialect, atom()} | {:encode_error, Jason.EncodeError.t() | String.t()} @doc """ Parses a SQL string and returns the single statement as an AST map. Returns `{:error, reason}` if zero or multiple statements are found. Use `parse_many/2` for multi-statement SQL. """ @spec parse(sql_string(), [sql_opt()]) :: {:ok, map()} | {:error, parse_error()} def parse(sql, opts \\ []) do case parse_many(sql, opts) do {:ok, [statement]} -> {:ok, statement} {:ok, []} -> {:error, "no statements found in SQL input"} {:ok, _many} -> {:error, "expected exactly one statement; use parse_many/2 for multiple"} {:error, _} = error -> error end end @doc """ Parses a SQL string and returns all statements as a list of AST maps. """ @spec parse_many(sql_string(), [sql_opt()]) :: {:ok, [map()]} | {:error, parse_error()} def parse_many(sql, opts \\ []) do dialect = Keyword.get(opts, :dialect, :generic) with :ok <- validate_dialect(dialect), {:ok, json} <- Native.parse_sql(sql, dialect_to_string(dialect)), {:ok, statements} <- Jason.decode(json), :ok <- validate_statements(statements) do {:ok, statements} end end @doc """ Reconstructs a SQL string from an AST map returned by `parse/2` or `parse_many/2`. Note: the `dialect:` option is validated but does not affect output. The underlying Rust serializer uses a dialect-agnostic Display implementation. """ @spec to_sql(map(), [sql_opt()]) :: {:ok, sql_string()} | {:error, parse_error()} def to_sql(ast, opts \\ []) do dialect = Keyword.get(opts, :dialect, :generic) with :ok <- validate_dialect(dialect), {:ok, json} <- encode_ast(ast) do Native.to_sql(json, dialect_to_string(dialect)) end end @doc "Returns all supported dialect atoms." @spec dialects() :: [dialect()] def dialects, do: @valid_dialects defp validate_dialect(d) when d in @valid_dialects, do: :ok defp validate_dialect(d), do: {:error, {:unknown_dialect, d}} defp validate_statements(stmts) when is_list(stmts) do if Enum.all?(stmts, &is_map/1), do: :ok, else: {:error, "unexpected NIF output: statements must be a list of maps"} end defp validate_statements(_), do: {:error, "unexpected NIF output: expected a JSON array"} defp encode_ast(ast) when is_map(ast) do case Jason.encode([ast]) do {:ok, _} = ok -> ok {:error, reason} -> {:error, {:encode_error, reason}} end end defp encode_ast(ast), do: {:error, {:encode_error, "expected a map, got: #{inspect(ast)}"}} defp dialect_to_string(dialect), do: Atom.to_string(dialect) end