Current section
Files
Jump to
Current section
Files
lib/phoenix/sync/predefined_shape.ex
defmodule Phoenix.Sync.PredefinedShape do
# A self-contained way to hold shape definition information, alongside stream
# configuration, compatible with both the embedded and HTTP API versions.
# Defers to the client code to validate shape options, so we can keep up with
# changes to the api without duplicating changes here
alias Electric.Client.ShapeDefinition
shape_schema_gen = fn required? ->
Keyword.take(
[table: [type: :string, required: required?]] ++ ShapeDefinition.schema_definition(),
ShapeDefinition.public_keys()
)
end
@shape_definition_schema shape_schema_gen.(false)
@keyword_shape_schema shape_schema_gen.(true)
@api_schema_opts [
storage: [type: {:or, [:map, nil]}]
]
@shape_schema NimbleOptions.new!(@shape_definition_schema)
@api_schema NimbleOptions.new!(@api_schema_opts)
@stream_schema Electric.Client.Stream.options_schema()
@public_schema NimbleOptions.new!(@shape_definition_schema ++ @api_schema_opts)
@api_schema_keys Keyword.keys(@api_schema_opts)
@stream_schema_keys Keyword.keys(@stream_schema.schema)
@shape_definition_keys ShapeDefinition.public_keys()
# we hold the query separate from the shape definition in order to allow
# for transformation of a query to a shape definition at runtime rather
# than compile time.
defstruct [
:shape_config,
:api_config,
:stream_config,
:query
]
@type t :: %__MODULE__{}
@type option() :: unquote(NimbleOptions.option_typespec(@public_schema))
@type options() :: [option()]
if Code.ensure_loaded?(Ecto) do
@type shape() :: options() | Phoenix.Sync.queryable()
else
@type shape() :: options()
end
@doc false
def shape_schema, do: @shape_schema
@doc false
def schema, do: @keyword_shape_schema
def is_queryable?(schema) when is_atom(schema) do
Code.ensure_loaded?(schema) && function_exported?(schema, :__schema__, 1) &&
!is_nil(schema.__schema__(:source))
end
def is_queryable?(q) when is_struct(q, Ecto.Query) or is_struct(q, Ecto.Changeset), do: true
def is_queryable?(_), do: false
@doc false
@spec new!(shape(), options()) :: t()
def new!(opts, config \\ [])
def new!(shape, opts) when is_list(shape) and is_list(opts) do
shape
|> Keyword.merge(opts)
|> split_and_validate_opts!(mode: :keyword)
|> new()
end
def new!(table, opts) when is_binary(table) and is_list(opts) do
new!([table: table], opts)
end
if Code.ensure_loaded?(Ecto) do
def new!(ecto_shape, opts)
when is_atom(ecto_shape) or is_struct(ecto_shape, Ecto.Query) or
is_function(ecto_shape, 1) or
is_struct(ecto_shape, Ecto.Changeset) do
opts
|> split_and_validate_opts!(mode: :ecto)
|> Keyword.merge(query: ecto_shape)
|> new()
end
end
defp new(opts), do: struct(__MODULE__, opts)
defp split_and_validate_opts!(opts, mode) do
{shape_opts, other_opts} = Keyword.split(opts, @shape_definition_keys)
{api_opts, other_opts} = Keyword.split(other_opts, @api_schema_keys)
stream_opts =
case Keyword.split(other_opts, @stream_schema_keys) do
{stream_opts, []} ->
stream_opts
{_stream_opts, invalid_opts} ->
raise ArgumentError,
message: "received invalid options to a shape definition: #{inspect(invalid_opts)}"
end
shape_config = validate_shape_config(shape_opts, mode)
api_config = NimbleOptions.validate!(api_opts, @api_schema)
# remove replica value from the stream because it will override the shape
# setting and since we've removed the `:replica` value earlier
# it'll always be set to default
stream_config =
NimbleOptions.validate!(stream_opts, @stream_schema)
|> Enum.reject(&is_nil(elem(&1, 1)))
|> Enum.reject(&(elem(&1, 0) == :replica))
[shape_config: shape_config, api_config: api_config, stream_config: stream_config]
end
# If we're defining a shape with a keyword list then we need at least the
# `table`. Coming from some ecto value, the table is already present
defp validate_shape_config(shape_opts, mode: :keyword) do
NimbleOptions.validate!(shape_opts, @keyword_shape_schema)
end
defp validate_shape_config(shape_opts, _mode) do
NimbleOptions.validate!(shape_opts, @shape_schema)
end
def client(%Electric.Client{} = client, %__MODULE__{} = predefined_shape) do
Electric.Client.merge_params(client, to_client_params(predefined_shape))
end
@doc false
def to_client_params(%__MODULE__{} = predefined_shape) do
predefined_shape
|> to_shape_definition()
|> ShapeDefinition.params()
end
@doc false
def to_api_params(%__MODULE__{} = predefined_shape) do
predefined_shape
|> to_shape_definition()
|> ShapeDefinition.params(format: :keyword)
|> Keyword.merge(predefined_shape.api_config)
end
@doc false
def to_shape_params(%__MODULE__{} = predefined_shape) do
predefined_shape
|> to_shape_definition()
|> ShapeDefinition.params(format: :keyword)
end
@doc false
def to_stream_params(%__MODULE__{} = predefined_shape) do
{to_shape_definition(predefined_shape), predefined_shape.stream_config}
end
defp to_shape_definition(%__MODULE__{query: nil, shape_config: shape_config}) do
ShapeDefinition.new!(shape_config)
end
if Code.ensure_loaded?(Ecto) do
# we resolve the query at runtime to avoid compile-time dependencies in
# router modules
defp to_shape_definition(%__MODULE__{query: queryable, shape_config: shape_config}) do
try do
Electric.Client.EctoAdapter.shape!(queryable, shape_config)
rescue
e in Protocol.UndefinedError ->
raise ArgumentError,
message: "Invalid query `#{inspect(queryable)}`: #{e.description}"
end
end
end
end