Skip to content

Commit a6e5a6b

Browse files
committed
add support for changeset shapes
and clean up the shape definition path, removing phoenix.sync from the chain of "things that understand shapes" so now if you change the client's view of a shape it should propagate to phoenix.sync without changes
1 parent d6ecaab commit a6e5a6b

File tree

9 files changed

+407
-146
lines changed

9 files changed

+407
-146
lines changed

lib/phoenix/sync/client.ex

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -124,26 +124,16 @@ defmodule Phoenix.Sync.Client do
124124
end
125125

126126
@doc false
127-
def stream(shape, stream_opts, sync_opts) do
128-
client = new!(sync_opts)
129-
{shape, shape_stream_opts} = resolve_shape(shape)
130-
Electric.Client.stream(client, shape, Keyword.merge(shape_stream_opts, stream_opts))
127+
# used for testing. `config` replace the application configuration
128+
def stream(shape, stream_opts, config) do
129+
client = new!(config)
130+
{shape, stream_opts} = resolve_shape(shape, stream_opts)
131+
Electric.Client.stream(client, shape, stream_opts)
131132
end
132133

133-
defp resolve_shape(table) when is_binary(table) do
134-
{table, []}
135-
end
136-
137-
defp resolve_shape(definition) when is_list(definition) do
138-
shape = PredefinedShape.new!(definition)
139-
PredefinedShape.to_stream_params(shape)
140-
end
141-
142-
defp resolve_shape(schema) when is_atom(schema) do
143-
{schema, []}
144-
end
145-
146-
defp resolve_shape(%Ecto.Query{} = query) do
147-
{query, []}
134+
defp resolve_shape(shape, stream_opts) do
135+
shape
136+
|> PredefinedShape.new!(stream_opts)
137+
|> Phoenix.Sync.PredefinedShape.to_stream_params()
148138
end
149139
end

lib/phoenix/sync/controller.ex

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,37 @@ defmodule Phoenix.Sync.Controller do
4141
end
4242
end
4343
44+
## Shape definitions
45+
46+
Shape definitions can be any of the following:
47+
48+
- An `Ecto.Schema` module:
49+
50+
sync_render(conn, MyPlugApp.Todos.Todo)
51+
52+
- An `Ecto` query:
53+
54+
sync_render(conn, params, from(t in Todos.Todo, where: t.owner_id == ^user_id))
55+
56+
- A `changeset/1` function which defines the table and columns:
57+
58+
sync_render(conn, params, &Todos.Todo.changeset/1)
59+
60+
- A `changeset/1` function plus a where clause:
61+
62+
sync_render(conn, params, &Todos.Todo.changeset/1, where: "completed = false")
63+
64+
or a parameterized where clause:
65+
66+
sync_render(conn, params, &Todos.Todo.changeset/1, where: "completed = $1", params: [false])
67+
68+
- A keyword list defining the shape parameters:
69+
70+
sync_render(conn, params, table: "todos", namespace: "my_app", where: "completed = $1", params: [false])
4471
"""
4572

4673
alias Phoenix.Sync.Plug.CORS
74+
alias Phoenix.Sync.PredefinedShape
4775

4876
defmacro __using__(opts \\ []) do
4977
# validate that we're being used in the context of a Plug.Router impl
@@ -78,25 +106,31 @@ defmodule Phoenix.Sync.Controller do
78106
@doc """
79107
Return the sync events for the given shape as a `Plug.Conn` response.
80108
"""
81-
@spec sync_render(Plug.Conn.t(), Plug.Conn.params(), Electric.Shapes.Api.shape_opts()) ::
82-
Plug.Conn.t()
83-
def sync_render(%{private: %{phoenix_endpoint: endpoint}} = conn, params, shape) do
109+
@spec sync_render(
110+
Plug.Conn.t(),
111+
Plug.Conn.params(),
112+
PredefinedShape.shape(),
113+
PredefinedShape.options()
114+
) :: Plug.Conn.t()
115+
def sync_render(conn, params, shape, shape_opts \\ [])
116+
117+
def sync_render(%{private: %{phoenix_endpoint: endpoint}} = conn, params, shape, shape_opts) do
84118
api =
85119
endpoint.config(:phoenix_sync) ||
86120
raise RuntimeError,
87121
message:
88122
"Please configure your Endpoint with [phoenix_sync: Phoenix.Sync.plug_opts()] in your `c:Application.start/2`"
89123

90-
sync_render_api(conn, api, params, shape)
124+
sync_render_api(conn, api, params, shape, shape_opts)
91125
end
92126

93127
# the Plug.{Router, Builder} version
94-
def sync_render(%{private: %{phoenix_sync_api: api}} = conn, params, shape) do
95-
sync_render_api(conn, api, params, shape)
128+
def sync_render(%{private: %{phoenix_sync_api: api}} = conn, params, shape, shape_opts) do
129+
sync_render_api(conn, api, params, shape, shape_opts)
96130
end
97131

98-
defp sync_render_api(conn, api, params, shape) do
99-
predefined_shape = Phoenix.Sync.PredefinedShape.new!(shape)
132+
defp sync_render_api(conn, api, params, shape, shape_opts) do
133+
predefined_shape = PredefinedShape.new!(shape, shape_opts)
100134

101135
{:ok, shape_api} = Phoenix.Sync.Adapter.PlugApi.predefined_shape(api, predefined_shape)
102136

lib/phoenix/sync/predefined_shape.ex

Lines changed: 103 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -3,163 +3,149 @@ defmodule Phoenix.Sync.PredefinedShape do
33

44
# A self-contained way to hold shape definition information, alongside stream
55
# configuration, compatible with both the embedded and HTTP API versions.
6+
# Defers to the client code to validate shape options, so we can keep up with
7+
# changes to the api without duplicating changes here
68

79
alias Electric.Client.ShapeDefinition
810

9-
@keys [
10-
:relation,
11-
:where,
12-
:columns,
13-
:replica,
14-
:storage
15-
]
11+
shape_schema_gen = fn required? ->
12+
Keyword.take(
13+
[table: [type: :string, required: required?]] ++ ShapeDefinition.schema_definition(),
14+
ShapeDefinition.public_keys()
15+
)
16+
end
1617

17-
@schema NimbleOptions.new!(
18-
table: [type: :string],
19-
query: [type: {:or, [:atom, {:struct, Ecto.Query}]}, doc: false],
20-
namespace: [type: :string, default: "public"],
21-
where: [type: :string],
22-
columns: [type: {:list, :string}],
23-
replica: [type: {:in, [:default, :full]}],
24-
storage: [type: {:or, [:map, nil]}]
25-
)
18+
@shape_definition_schema shape_schema_gen.(false)
19+
@keyword_shape_schema shape_schema_gen.(true)
2620

27-
defstruct [:query | @keys]
21+
@api_schema_opts [
22+
storage: [type: {:or, [:map, nil]}]
23+
]
24+
25+
@shape_schema NimbleOptions.new!(@shape_definition_schema)
26+
@api_schema NimbleOptions.new!(@api_schema_opts)
27+
@stream_schema Electric.Client.Stream.options_schema()
28+
@public_schema NimbleOptions.new!(@shape_definition_schema ++ @api_schema_opts)
29+
30+
@api_schema_keys Keyword.keys(@api_schema_opts)
31+
@stream_schema_keys Keyword.keys(@stream_schema.schema)
32+
@shape_definition_keys ShapeDefinition.public_keys()
33+
34+
# we hold the query separate from the shape definition in order to allow
35+
# for transformation of a query to a shape definition at runtime rather
36+
# than compile time.
37+
defstruct [
38+
:shape_config,
39+
:api_config,
40+
:stream_config,
41+
:query
42+
]
2843

2944
@type t :: %__MODULE__{}
45+
@type options() :: [unquote(NimbleOptions.option_typespec(@public_schema))]
3046

31-
def schema, do: @schema
32-
def keys, do: @keys
47+
if Code.ensure_loaded?(Ecto) do
48+
@type shape() :: options() | Electric.Client.ecto_shape()
49+
else
50+
@type shape() :: options()
51+
end
52+
53+
def schema, do: @public_schema
3354

55+
@spec new!(shape(), options()) :: t()
3456
def new!(opts, config \\ [])
3557

36-
def new!(shape, opts) when is_list(opts) and is_list(shape) do
37-
config = NimbleOptions.validate!(Keyword.merge(shape, opts), @schema)
38-
new(Keyword.put(config, :relation, build_relation!(config)))
58+
def new!(shape, opts) when is_list(shape) and is_list(opts) do
59+
shape
60+
|> Keyword.merge(opts)
61+
|> split_and_validate_opts!(mode: :keyword)
62+
|> new()
3963
end
4064

41-
def new!(schema, opts) when is_atom(schema) do
42-
new(Keyword.put(opts, :query, schema))
65+
def new!(table, opts) when is_binary(table) and is_list(opts) do
66+
new!([table: table], opts)
4367
end
4468

45-
def new!(%Ecto.Query{} = query, opts) do
46-
new(Keyword.put(opts, :query, query))
69+
if Code.ensure_loaded?(Ecto) do
70+
def new!(ecto_shape, opts)
71+
when is_atom(ecto_shape) or is_struct(ecto_shape, Ecto.Query) or
72+
is_function(ecto_shape, 1) or
73+
is_struct(ecto_shape, Ecto.Changeset) do
74+
opts
75+
|> split_and_validate_opts!(mode: :ecto)
76+
|> Keyword.merge(query: ecto_shape)
77+
|> new()
78+
end
4779
end
4880

49-
defp new(opts) do
50-
struct(__MODULE__, opts)
51-
end
81+
defp new(opts), do: struct(__MODULE__, opts)
5282

53-
defp build_relation!(opts) do
54-
build_relation(opts) ||
55-
raise ArgumentError,
56-
message: "missing relation or table in #{inspect(opts)}"
57-
end
83+
defp split_and_validate_opts!(opts, mode) do
84+
{shape_opts, other_opts} = Keyword.split(opts, @shape_definition_keys)
85+
{api_opts, other_opts} = Keyword.split(other_opts, @api_schema_keys)
5886

59-
defp build_relation(opts) do
60-
case Keyword.get(opts, :relation) do
61-
{_namespace, _table} = relation ->
62-
relation
87+
stream_opts =
88+
case Keyword.split(other_opts, @stream_schema_keys) do
89+
{stream_opts, []} ->
90+
stream_opts
6391

64-
nil ->
65-
case Keyword.get(opts, :table) do
66-
table when is_binary(table) ->
67-
namespace = Keyword.get(opts, :namespace, "public")
68-
{namespace, table}
92+
{_stream_opts, invalid_opts} ->
93+
raise ArgumentError,
94+
message: "received invalid options to a shape definition: #{inspect(invalid_opts)}"
95+
end
6996

70-
_ ->
71-
nil
72-
end
97+
shape_config = validate_shape_config(shape_opts, mode)
98+
api_config = NimbleOptions.validate!(api_opts, @api_schema)
7399

74-
_ ->
75-
nil
76-
end
100+
# remove replica value from the stream because it will override the shape
101+
# setting and since we've removed the `:replica` value earlier
102+
# it'll always be set to default
103+
stream_config =
104+
NimbleOptions.validate!(stream_opts, @stream_schema)
105+
|> Enum.reject(&is_nil(elem(&1, 1)))
106+
|> Enum.reject(&(elem(&1, 0) == :replica))
107+
108+
[shape_config: shape_config, api_config: api_config, stream_config: stream_config]
109+
end
110+
111+
# If we're defining a shape with a keyword list then we need at least the
112+
# `table`. Coming from some ecto value, the table is already present
113+
defp validate_shape_config(shape_opts, mode: :keyword) do
114+
NimbleOptions.validate!(shape_opts, @keyword_shape_schema)
115+
end
116+
117+
defp validate_shape_config(shape_opts, _mode) do
118+
NimbleOptions.validate!(shape_opts, @shape_schema)
77119
end
78120

79121
def client(%Electric.Client{} = client, %__MODULE__{} = predefined_shape) do
80122
Electric.Client.merge_params(client, to_client_params(predefined_shape))
81123
end
82124

83-
defp to_client_params(%__MODULE__{} = predefined_shape) do
84-
{{namespace, table}, shape} =
85-
predefined_shape
86-
|> resolve_query()
87-
|> to_list()
88-
|> Keyword.pop!(:relation)
89-
90-
# Remove storage as it's not currently supported as a query param
91-
shape
92-
|> Keyword.put(:table, ShapeDefinition.url_table_name(namespace, table))
93-
|> Keyword.delete(:storage)
94-
|> columns_to_query_param()
125+
def to_client_params(%__MODULE__{} = predefined_shape) do
126+
predefined_shape
127+
|> to_shape_definition()
128+
|> ShapeDefinition.params()
95129
end
96130

97131
def to_api_params(%__MODULE__{} = predefined_shape) do
98132
predefined_shape
99-
|> resolve_query()
100-
|> to_list()
133+
|> to_shape_definition()
134+
|> ShapeDefinition.params(format: :keyword)
135+
|> Keyword.merge(predefined_shape.api_config)
101136
end
102137

103138
def to_stream_params(%__MODULE__{} = predefined_shape) do
104-
{{namespace, table}, shape} =
105-
predefined_shape
106-
|> resolve_query()
107-
|> to_list()
108-
|> Keyword.pop!(:relation)
109-
110-
{shape_opts, stream_opts} = Keyword.split(shape, ShapeDefinition.public_keys())
111-
112-
{:ok, shape_definition} =
113-
ShapeDefinition.new(table, Keyword.merge(shape_opts, namespace: namespace))
114-
115-
{shape_definition, stream_opts}
139+
{to_shape_definition(predefined_shape), predefined_shape.stream_config}
116140
end
117141

118-
defp resolve_query(%__MODULE__{query: nil} = predefined_shape) do
119-
predefined_shape
142+
defp to_shape_definition(%__MODULE__{query: nil, shape_config: shape_config}) do
143+
ShapeDefinition.new!(shape_config)
120144
end
121145

122146
# we resolve the query at runtime to avoid compile-time dependencies in
123147
# router modules
124-
defp resolve_query(%__MODULE__{} = predefined_shape) do
125-
from_queryable!(predefined_shape)
126-
end
127-
128-
defp from_queryable!(%{query: queryable} = predefined_shape) do
129-
queryable
130-
|> Electric.Client.EctoAdapter.shape_from_query!()
131-
|> from_shape_definition(predefined_shape)
132-
end
133-
134-
defp from_shape_definition(%ShapeDefinition{} = shape_definition, predefined_shape) do
135-
%{
136-
namespace: namespace,
137-
table: table,
138-
where: where,
139-
columns: columns
140-
} = shape_definition
141-
142-
%{predefined_shape | relation: {namespace || "public", table}, columns: columns}
143-
|> put_if(:where, where)
144-
end
145-
146-
defp put_if(shape, _key, nil), do: shape
147-
defp put_if(shape, key, value), do: Map.put(shape, key, value)
148-
149-
defp to_list(%__MODULE__{} = shape) do
150-
Enum.flat_map(@keys, fn key ->
151-
value = Map.fetch!(shape, key)
152-
153-
if !is_nil(value),
154-
do: [{key, value}],
155-
else: []
156-
end)
157-
end
158-
159-
defp columns_to_query_param(shape) do
160-
case Keyword.get(shape, :columns) do
161-
columns when is_list(columns) -> Keyword.put(shape, :columns, Enum.join(columns, ","))
162-
_ -> shape
163-
end
148+
defp to_shape_definition(%__MODULE__{query: queryable, shape_config: shape_config}) do
149+
Electric.Client.EctoAdapter.shape!(queryable, shape_config)
164150
end
165151
end

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy