defmodule PgRest.Parser.Select do
@moduledoc """
Parses PostgREST select parameter into an AST.
Supports field selection, embed preloading with `!inner` modifier,
embed aliasing via `alias:name(...)`, nested embeds, and empty
embeds for anti-joins.
## Examples
iex> PgRest.Parser.Select.parse("id,name,email")
{:ok, [%{type: :field, name: "id"}, %{type: :field, name: "name"}, %{type: :field, name: "email"}]}
iex> PgRest.Parser.Select.parse("id,posts(id,title)")
{:ok, [%{type: :field, name: "id"}, %{type: :embed, name: "posts", fields: ["id", "title"], inner: false}]}
"""
alias PgRest.Parser.Splitter
@doc """
Parses a select parameter string into a list of field and embed AST nodes.
"""
@spec parse(String.t()) :: {:ok, [map()]}
def parse(select_str) when is_binary(select_str) do
fields = parse_fields(select_str)
{:ok, fields}
end
defp parse_fields(str) do
str
|> split_top_level()
|> Enum.map(&parse_field/1)
end
# Parses a single field or embed expression.
defp parse_field(field_str) do
field_str = String.trim(field_str)
{alias_name, rest} = extract_alias(field_str)
case parse_embed(rest) do
{:embed, name, inner?, inner_str} ->
fields = parse_inner_fields(inner_str)
embed = %{type: :embed, name: name, fields: fields, inner: inner?}
if alias_name, do: Map.put(embed, :alias, alias_name), else: embed
:not_embed ->
%{type: :field, name: rest}
end
end
# Extracts an optional alias prefix from "alias:rest" syntax.
# Returns `{alias, rest}` or `{nil, original}`.
defp extract_alias(str) do
case Regex.run(~r/^(\w+):(.+)$/, str) do
[_, alias_name, rest] -> {alias_name, rest}
nil -> {nil, str}
end
end
# Parses embed syntax variants:
# - "name(fields)" -> standard embed
# - "name!inner(fields)" -> inner join embed
# - "name()" -> empty embed (for anti-joins)
defp parse_embed(str) do
case Regex.run(~r/^(\w+)(!inner)?\((.*)\)$/s, str) do
[_, name, "!inner", inner] -> {:embed, name, true, inner}
[_, name, "", inner] -> {:embed, name, false, inner}
nil -> :not_embed
end
end
# Recursively parses the fields inside embed parentheses.
# Returns a list of strings (field names) and nested embed maps.
defp parse_inner_fields(""), do: []
defp parse_inner_fields("*"), do: ["*"]
defp parse_inner_fields(inner_str) do
inner_str
|> split_top_level()
|> Enum.map(&parse_field/1)
|> Enum.map(fn
%{type: :field, name: name} -> name
%{type: :embed} = nested -> nested
end)
end
# Splits a string on top-level commas, respecting nested parentheses.
defp split_top_level(str), do: Splitter.split_top_level(str)
end