Current section
Files
Jump to
Current section
Files
lib/mix/parser.ex
defmodule Supabase.PostgREST.Parser do
@moduledoc "simple parser for create statements on postgresql"
@type ast :: list(table | rls_policy)
@type table :: {table_name, columns :: list(column)}
@type column :: {name :: String.t(), attrs :: list(column_def)}
@type column_def ::
{:type, String.t()}
| {:primary, boolean}
| {:null, boolean}
| {:references, table_name}
| {:default, String.t()}
@type table_name :: {schema :: String.t(), name :: String.t()} | String.t()
@type rls_policy :: {name :: String.t(), rls_config :: list(rls_option)}
@type rls_option ::
{:type, :permissive | :restricted}
| {:on, table_name}
| {:for, :all | :insert | :update | :delete | :select}
| {:role, String.t()}
| {:using, String.t()}
| {:with_check, String.t()}
@spec run(input :: binary) :: {:ok, ast} | {:error, atom}
def run(input) when is_binary(input) do
input = split_input(input)
with {:ok, ast} <- parse(input, []) do
reversed_ast = Enum.reverse(ast)
{:ok, apply_primary_keys(reversed_ast)}
end
end
def split_input(input) do
input
|> String.trim()
|> String.downcase()
|> String.replace(~r/\"/, "")
|> String.split(~r"\s+|\,|\;|\(|\)", trim: true, include_captures: true)
|> Enum.reject(&String.match?(&1, ~r/\s+/))
|> Enum.map(&String.trim/1)
end
defp parse([], ast), do: {:ok, ast}
defp parse(["create", "table" | rest], ast) do
parse_table(rest, ast)
end
defp parse(["create", "policy" | rest], ast) do
parse_policy(rest, ast)
end
defp parse(["alter", "table" | rest], ast) do
parse_alter_table(rest, ast)
end
defp parse([_ | rest], ast), do: parse(rest, ast)
defp parse_table(["if", "not", "exists" | rest], ast) do
{rest, ast} = parse_table(rest, ast)
parse(rest, ast)
end
defp parse_table([name, "(" | rest], ast) do
table_name =
case String.split(name, ".") do
[name] -> name
[schema, name] -> {schema, name}
end
{rest, cols} = parse_table_cols(rest)
{rest, [{table_name, cols} | ast]}
end
defp parse_table_cols(rest) do
{cols_tokens, rest} = Enum.split_while(rest, &(&1 != ";"))
cols =
cols_tokens
|> split_by_comma()
|> Enum.reject(&(&1 == []))
|> Enum.map(&parse_column/1)
|> Enum.reject(&is_nil/1)
{rest, cols}
end
defp split_by_comma(tokens) do
tokens
|> Enum.chunk_by(&(&1 == ","))
|> Enum.reject(&(&1 == [","]))
end
defp parse_column(["constraint" | _rest]), do: nil
defp parse_column([name | attrs]) do
{name, parse_column_attrs(attrs, [])}
end
defp parse_column_attrs([], acc), do: Enum.reverse(acc)
defp parse_column_attrs(["not", "null" | rest], acc) do
parse_column_attrs(rest, [{:null, false} | acc])
end
defp parse_column_attrs(["null" | rest], acc) do
parse_column_attrs(rest, [{:null, true} | acc])
end
defp parse_column_attrs(["primary", "key" | rest], acc) do
parse_column_attrs(rest, [{:primary, true} | acc])
end
defp parse_column_attrs(["default", value | rest], acc) do
parse_column_attrs(rest, [{:default, value} | acc])
end
defp parse_column_attrs(["references", table | rest], acc) do
parse_column_attrs(rest, [{:references, table} | acc])
end
defp parse_column_attrs([type | rest], acc)
when type in ["character", "varchar", "text"] do
parse_column_attrs(rest, [{:type, "string"} | acc])
end
defp parse_column_attrs([type | rest], acc) do
case map_pg_type(type) do
nil -> parse_column_attrs(rest, acc)
ecto_type -> parse_column_attrs(rest, [{:type, ecto_type} | acc])
end
end
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp map_pg_type(type) do
cond do
int_type?(type) -> "integer"
float_type?(type) -> "float"
map_type?(type) -> "map"
type == "interval" -> "duration"
type == "timestamp" -> "naive_datetime"
type == "timestampz" -> "utc_datetime"
type == "timez" -> "time_usec"
type == "uuid" -> "binary_id"
type == "bytea" -> "binary"
decimal_type?(type) -> "decimal"
as_is_type?(type) -> type
true -> nil
end
end
defp int_type?(type) do
type in ["integer", "bigint", "smallint", "serial", "bigserial"]
end
defp as_is_type?(type), do: type in ["boolean", "date", "time"]
defp decimal_type?(type), do: type in ["numeric", "decimal"]
defp float_type?(type), do: type in ["real", "numeric"]
defp map_type?(type), do: type in ["json", "jsonb"]
defp parse_policy([name | rest], ast) do
{rest, opts} = parse_policy_opts(rest, [])
parse(rest, [{name, opts} | ast])
end
defp parse_policy_opts(["on" | rest], acc) do
{table, rest} = parse_table_name(rest)
parse_policy_opts(rest, [{:on, table} | acc])
end
defp parse_policy_opts(["as", type | rest], acc) when type in ["permissive", "restrictive"] do
type_atom = if type == "permissive", do: :permissive, else: :restrictive
parse_policy_opts(rest, [{:type, type_atom} | acc])
end
defp parse_policy_opts(["for", action | rest], acc)
when action in ["all", "select", "insert", "update", "delete"] do
parse_policy_opts(rest, [{:for, String.to_atom(action)} | acc])
end
defp parse_policy_opts(["to", role | rest], acc) do
parse_policy_opts(rest, [{:role, role} | acc])
end
defp parse_policy_opts(["using", "(" | rest], acc) do
{expr, rest} = parse_expression(rest, [])
parse_policy_opts(rest, [{:using, expr} | acc])
end
defp parse_policy_opts(["with", "check", "(" | rest], acc) do
{expr, rest} = parse_expression(rest, [])
parse_policy_opts(rest, [{:with_check, expr} | acc])
end
defp parse_policy_opts([";" | rest], acc) do
{rest, Enum.reverse(acc)}
end
defp parse_policy_opts([_ | rest], acc) do
parse_policy_opts(rest, acc)
end
defp parse_policy_opts([], acc), do: {[], Enum.reverse(acc)}
defp parse_table_name([name | rest]) do
table_name =
case String.split(name, ".") do
[name] -> name
[schema, name] -> {schema, name}
end
{table_name, rest}
end
defp parse_expression([")" | rest], acc) do
{Enum.reverse(acc) |> Enum.join(" "), rest}
end
defp parse_expression([token | rest], acc) do
parse_expression(rest, [token | acc])
end
# Parse ALTER TABLE statements for PRIMARY KEY constraints
defp parse_alter_table(["only" | rest], ast) do
parse_alter_table(rest, ast)
end
defp parse_alter_table([table_name | rest], ast) do
case rest do
["add", "constraint", _name, "primary", "key", "(" | rest] ->
{column, rest} = extract_column_name(rest)
table = parse_table_identifier(table_name)
parse(rest, [{:pk, table, column} | ast])
_ ->
skip_to_semicolon(rest, ast)
end
end
defp parse_table_identifier(name) do
case String.split(name, ".") do
[name] -> name
[schema, name] -> {schema, name}
end
end
defp extract_column_name([")" | rest]), do: {nil, rest}
defp extract_column_name([col, ")" | rest]), do: {col, rest}
defp extract_column_name([_col | rest]), do: extract_column_name(rest)
defp skip_to_semicolon([";" | rest], ast), do: parse(rest, ast)
defp skip_to_semicolon([_ | rest], ast), do: skip_to_semicolon(rest, ast)
defp skip_to_semicolon([], ast), do: parse([], ast)
# Apply primary key constraints to tables
defp apply_primary_keys(ast) do
ast
|> Enum.reduce([], &inject_primary_keys/2)
|> Enum.reverse()
end
defp inject_primary_keys({:pk, table, column}, acc) do
Enum.map(acc, &update_table_primary_key(&1, table, column))
end
defp inject_primary_keys(item, acc), do: [item | acc]
defp update_table_primary_key({table, columns}, table, column) when is_list(columns) do
{table, mark_column_as_primary(columns, column)}
end
defp update_table_primary_key(other, _table, _column), do: other
defp mark_column_as_primary(columns, primary_column) do
Enum.map(columns, fn
{^primary_column, attrs} -> {primary_column, [{:primary, true} | attrs]}
other -> other
end)
end
end