Current section

Files

Jump to
electric_client lib electric client shape_definition.ex
Raw

lib/electric/client/shape_definition.ex

defmodule Electric.Client.ShapeDefinition do
@moduledoc """
Struct for defining a shape.
iex> ShapeDefinition.new("items", where: "something = true")
{:ok, %ShapeDefinition{table: "items", where: "something = true"}}
"""
alias Electric.Client.Util
@public_keys [:namespace, :table, :columns, :where, :params]
@derive {Jason.Encoder, only: @public_keys}
@enforce_keys [:table]
defstruct @public_keys ++ [parser: {Electric.Client.ValueMapper, []}]
@schema NimbleOptions.new!(
where: [
type: {:or, [nil, :string]},
required: false,
default: nil,
doc: "Filter the table according to the where clause."
],
columns: [
type: {:or, [nil, {:list, :string}]},
default: nil,
doc:
"List of columns to include in the shape. Must include all primary keys. If `nil` this is equivalent to all columns (`SELECT *`)"
],
namespace: [
type: {:or, [nil, :string]},
required: false,
default: nil,
doc:
"The namespace the table belongs to. If `nil` then Postgres will use whatever schema is the default (usually `public`)."
],
params: [
type: {:or, [nil, {:map, :pos_integer, :string}, {:list, :string}]},
default: nil,
doc:
"Values of positional parameters in the where clause. These will substitute `$i` placeholder in the where clause."
],
parser: [
type: :mod_arg,
default: {Electric.Client.ValueMapper, []},
doc: """
A `{module, args}` tuple specifying the `Electric.Client.ValueMapper`
implementation to use for mapping values from the sync stream into Elixir
terms.
"""
]
)
@type t :: %__MODULE__{
namespace: String.t() | nil,
table: String.t(),
columns: [String.t(), ...] | nil,
where: nil | String.t(),
parser: {atom(), term()}
}
@type option :: unquote(NimbleOptions.option_typespec(@schema))
@type options :: [option()]
@spec new(String.t() | keyword()) :: {:ok, t()} | {:error, term()}
def new(table_name) when is_binary(table_name) do
new(table_name, [])
end
def new(opts) when is_list(opts) do
{table, opts} = Keyword.pop(opts, :table, nil)
new(table, opts)
end
@doc """
Create a `ShapeDefinition` for the given `table_name`.
## Options
#{NimbleOptions.docs(@schema)}
"""
@spec new(String.t(), options()) :: {:ok, t()} | {:error, term()}
def new(table_name, opts) when is_binary(table_name) do
with {:ok, opts} <- NimbleOptions.validate(opts, @schema) do
{:ok,
%__MODULE__{
table: table_name,
where: Access.get(opts, :where),
columns: Access.get(opts, :columns),
namespace: Access.get(opts, :namespace),
parser: Access.get(opts, :parser),
params: Access.get(opts, :params)
}}
end
end
@spec new!(String.t(), options()) :: t() | no_return()
def new!(table_name, opts \\ []) do
case new(table_name, opts) do
{:ok, shape} -> shape
{:error, %NimbleOptions.ValidationError{} = error} -> raise error
end
end
def public_keys, do: @public_keys
@doc """
Return a string representation of the shape's table, quoted for use in API URLs.
iex> ShapeDefinition.url_table_name(ShapeDefinition.new!("my_table"))
"my_table"
iex> ShapeDefinition.url_table_name(ShapeDefinition.new!("my_table", namespace: "my_app"))
"my_app.my_table"
iex> ShapeDefinition.url_table_name(ShapeDefinition.new!("my table", namespace: "my app"))
~s["my app"."my table"]
"""
@spec url_table_name(t()) :: String.t()
def url_table_name(%__MODULE__{namespace: nil, table: table}) do
safe_url_name(table)
end
def url_table_name(%__MODULE__{namespace: namespace, table: table}) do
url_table_name(namespace, table)
end
def url_table_name(namespace, table) do
IO.iodata_to_binary([safe_url_name(namespace), ".", safe_url_name(table)])
end
defp safe_url_name(name) do
if name =~ ~r/^[a-z_][a-z0-9_]*$/ do
name
else
quote_table_name(name)
end
end
defp quote_table_name(name) do
IO.iodata_to_binary([
?",
:binary.replace(name, ~s["], ~s[""], [:global]),
?"
])
end
@doc """
Tests if two `%ShapeDefinition{}` instances are equal, ignoring the `parser`
setting.
## Example
iex> {:ok, shape1} = Electric.Client.ShapeDefinition.new("items")
iex> {:ok, shape2} = Electric.Client.ShapeDefinition.new("items")
iex> Electric.Client.ShapeDefinition.matches?(shape1, shape2)
true
iex> {:ok, shape3} = Electric.Client.ShapeDefinition.new("items", where: "something = 'here'")
iex> Electric.Client.ShapeDefinition.matches?(shape1, shape3)
false
"""
@spec matches?(t(), t()) :: boolean()
def matches?(%__MODULE__{} = shape1, %__MODULE__{} = shape2) do
Map.take(shape1, @public_keys) == Map.take(shape2, @public_keys)
end
def matches?(_term1, _term2), do: false
@doc false
@spec params(t(), [{:format, :query | :json}]) :: Electric.Client.Fetch.Request.params()
def params(%__MODULE__{} = shape, opts \\ []) do
%{where: where, columns: columns, params: params} = shape
table_name = url_table_name(shape)
format = Keyword.get(opts, :format, :query)
%{table: table_name}
|> Util.map_put_if(:where, where, is_binary(where))
|> Util.map_put_if(
:columns,
fn -> params_columns_list(columns, format) end,
is_list(columns)
)
|> maybe_add_where_params(:params, params, format)
end
defp params_columns_list(columns, :query) when is_list(columns) do
Enum.join(columns, ",")
end
defp params_columns_list(columns, :json) when is_list(columns) do
columns
end
defp maybe_add_where_params(input, _, nil, _), do: input
defp maybe_add_where_params(input, key, params, format) do
params
|> normalize_params_form()
|> then(&put_param_map(input, key, &1, format))
end
defp normalize_params_form(params) when is_list(params) do
params
|> Enum.with_index(fn elem, index -> {to_string(index + 1), to_string(elem)} end)
|> Map.new()
end
defp normalize_params_form(params) when is_map(params) do
Map.new(params, fn {k, v} -> {to_string(k), to_string(v)} end)
end
defp put_param_map(input, key, params, :query) do
params
|> Map.new(fn {k, v} -> {"#{key}[#{k}]", v} end)
|> Map.merge(input)
end
defp put_param_map(input, key, params, :json) do
Map.put(input, key, params)
end
end