Skip to content

Commit 3f43346

Browse files
authored
elixir-client: Build shape definition from changeset (#2801)
Allows for generating a ShapeDefinition from an Ecto.Changeset Also bundles a few quality-of-life changes: - ShapeDefinition.params/1 returns a `%{binary => binary}` map in query string mode, rather than a mix of atom and binary keys - Widen accepted types for where parameters from `:string` to [:string, :integer, :float, :boolean]` to make life simpler for user. these types are trivial to convert to strings so why not do that for people - Give ShapeDefinitions a `replica` setting. This is how it should have been to begin with -- the replica mode is a property of the shape, not of the stream - Expose the ShapeDefinition validation schema so we can split options and validate partial shape parameters (Phoenix.Sync.PredefinedShape accepts a big bag of options that we need to assign appropriately)
1 parent a148509 commit 3f43346

12 files changed

Lines changed: 713 additions & 96 deletions

File tree

.changeset/lazy-rivers-camp.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@core/elixir-client": patch
3+
---
4+
5+
Support generating shape definitions from Ecto.Changeset structs, add replica mode to client ShapeDefinitions, ensure client parameters are always of type %{binary() => binary()} and expose some options schema information for use in Phoenix.Sync

packages/elixir-client/lib/electric/client.ex

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,15 @@ defmodule Electric.Client do
239239
endpoint: URI.t(),
240240
fetch: {module(), term()}
241241
}
242+
if Code.ensure_loaded?(Ecto) do
243+
@type ecto_shape() :: Ecto.Queryable.t() | Ecto.Changeset.t() | (map() -> Ecto.Changeset.t())
244+
245+
# queryable is a schema module, an ecto query, a function that returns a changeset
246+
# or a changeset
247+
defguardp is_ecto_shape(ecto_queryable)
248+
when is_atom(ecto_queryable) or is_struct(ecto_queryable, Ecto.Query) or
249+
is_function(ecto_queryable, 1) or is_struct(ecto_queryable, Ecto.Changeset)
250+
end
242251

243252
@doc """
244253
Create a new client.
@@ -356,6 +365,13 @@ defmodule Electric.Client do
356365
end
357366
end
358367

368+
defp validate_queryable!(%Ecto.Query{} = query), do: query
369+
defp validate_queryable!(%Ecto.Changeset{} = changeset), do: changeset
370+
371+
defp validate_queryable!(changeset_fun) when is_function(changeset_fun, 1) do
372+
changeset_fun
373+
end
374+
359375
@doc """
360376
Create a [`ShapeDefinition`](`Electric.Client.ShapeDefinition`) from an `Ecto` query.
361377
@@ -364,20 +380,26 @@ defmodule Electric.Client do
364380
365381
iex> query = from(t in MyApp.Todo, where: t.completed == false)
366382
iex> Elixir.Client.shape!(query)
367-
%Electric.Client.ShapeDefinition{table: "todos" where: "(\\"completed\\" = FALSE)"}
383+
%Electric.Client.ShapeDefinition{table: "todos", where: "(\\"completed\\" = FALSE)"}
384+
385+
Also takes an `Ecto.Changeset` or 1-arity function returning an `Ecto.Changeset`:
386+
387+
iex> Elixir.Client.shape!(&MyApp.Todo.changeset/1)
388+
%Electric.Client.ShapeDefinition{table: "todos", columns: ["title", "completed"]}
389+
390+
Passing a changeset will filter the table columns according to the applied
391+
validations so if you want a shape to include a column, you must ensure
392+
that it has some kind of validation, using e.g.
393+
`Ecto.Changeset.validate_required/2`
368394
369395
Values from the Electric change stream will be mapped to instances of the
370396
passed `Ecto.Schema` module.
371397
"""
372-
@spec shape!(Ecto.Queryable.t()) :: ShapeDefinition.t() | no_return()
373-
def shape!(queryable) when is_atom(queryable) do
398+
@spec shape!(ecto_shape()) :: ShapeDefinition.t() | no_return()
399+
def shape!(queryable) when is_ecto_shape(queryable) do
374400
queryable
375401
|> validate_queryable!()
376-
|> Electric.Client.EctoAdapter.shape_from_query!()
377-
end
378-
379-
def shape!(%Ecto.Query{} = query) do
380-
Electric.Client.EctoAdapter.shape_from_query!(query)
402+
|> Electric.Client.EctoAdapter.shape!()
381403
end
382404
end
383405

@@ -389,10 +411,8 @@ defmodule Electric.Client do
389411
@doc """
390412
A shortcut to [`ShapeDefinition.new!/2`](`Electric.Client.ShapeDefinition.new!/2`).
391413
"""
392-
def shape!(table_or_query, opts \\ [])
393-
394414
@spec shape!(String.t(), ShapeDefinition.options()) :: ShapeDefinition.t() | no_return()
395-
def shape!(table_name, opts) when is_binary(table_name) do
415+
def shape!(table_name, opts \\ []) when is_binary(table_name) do
396416
ShapeDefinition.new!(table_name, opts)
397417
end
398418

@@ -424,7 +444,7 @@ defmodule Electric.Client do
424444
- Using a full shape definition:
425445
426446
{:ok, client} = Electric.Client.new(base_url: "http://localhost:3000")
427-
{:ok, shape} = Electric.Client.shape("todos", where: "completed = false")
447+
{:ok, shape} = Electric.Client.shape("todos", where: "completed = false", replica: :full)
428448
stream = Electric.Client.stream(client, shape)
429449
430450
- Or with an `Ecto` query or `Ecto.Schema` struct:
@@ -448,7 +468,7 @@ defmodule Electric.Client do
448468
end
449469

450470
if Code.ensure_loaded?(Ecto) do
451-
@spec stream(t(), String.t() | Ecto.Queryable.t()) :: Enumerable.t(message())
471+
@spec stream(t(), String.t() | ecto_shape()) :: Enumerable.t(message())
452472
else
453473
@spec stream(t(), String.t()) :: Enumerable.t(message())
454474
end
@@ -482,16 +502,11 @@ defmodule Electric.Client do
482502
@spec stream(t(), shape(), stream_options()) :: Enumerable.t(message())
483503

484504
if Code.ensure_loaded?(Ecto) do
485-
def stream(%Client{} = client, queryable, opts) when is_atom(queryable) do
505+
def stream(%Client{} = client, queryable, opts) when is_ecto_shape(queryable) do
486506
shape_definition = shape!(queryable)
487507

488508
stream(client, shape_definition, opts)
489509
end
490-
491-
def stream(%Client{} = client, %Ecto.Query{} = query, opts) do
492-
shape_definition = shape!(query)
493-
stream(client, shape_definition, opts)
494-
end
495510
end
496511

497512
def stream(%Client{} = client, table_name, opts) when is_binary(table_name) do

packages/elixir-client/lib/electric/client/authenticator/mock_authenticator.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ defmodule Electric.Client.Authenticator.MockAuthenticator do
2222
end
2323

2424
defp shape_hash(params, config) do
25-
auth_hash([:table, :columns, :where], params, config)
25+
auth_hash(["table", "columns", "where"], params, config)
2626
end
2727

2828
defp put_request_params(request, hash) do

packages/elixir-client/lib/electric/client/ecto_adapter.ex

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,18 @@ if Code.ensure_loaded?(Ecto) do
66

77
@behaviour Electric.Client.ValueMapper
88

9+
def shape!(schema, opts \\ [])
10+
11+
def shape!(schema, opts) when is_atom(schema), do: shape_from_query!(schema, opts)
12+
def shape!(%Ecto.Query{} = query, opts), do: shape_from_query!(query, opts)
13+
def shape!(%Ecto.Changeset{} = changeset, opts), do: shape_from_changeset!(changeset, opts)
14+
15+
def shape!(changeset_fun, opts) when is_function(changeset_fun, 1),
16+
do: shape_from_changeset!(changeset_fun, opts)
17+
918
@doc false
1019
@spec shape_from_query!(Ecto.Queryable.t()) :: ShapeDefinition.t()
11-
def shape_from_query!(queryable) do
20+
def shape_from_query!(queryable, opts \\ []) do
1221
query = Ecto.Queryable.to_query(queryable)
1322

1423
validate_query!(query)
@@ -20,14 +29,79 @@ if Code.ensure_loaded?(Ecto) do
2029
columns = query_columns(query)
2130
where = where(query)
2231

23-
ShapeDefinition.new!(table_name,
24-
namespace: namespace,
25-
where: where,
26-
columns: columns,
27-
parser: {__MODULE__, struct}
32+
ShapeDefinition.new!(
33+
table_name,
34+
merge_shape_opts(
35+
[namespace: namespace, where: where, columns: columns, parser: {__MODULE__, struct}],
36+
opts
37+
)
2838
)
2939
end
3040

41+
@shape_from_changeset_opts_schema ShapeDefinition.schema_definition()
42+
|> Keyword.drop([:columns])
43+
|> NimbleOptions.new!()
44+
45+
@type shape_from_changeset_opts() :: [
46+
unquote(NimbleOptions.option_typespec(@shape_from_changeset_opts_schema))
47+
]
48+
49+
@spec shape_from_changeset!(
50+
(map() -> Ecto.Changeset.t()) | Ecto.Changeset.t(),
51+
shape_from_changeset_opts()
52+
) :: ShapeDefinition.t()
53+
def shape_from_changeset!(changeset_or_fun, opts \\ [])
54+
55+
def shape_from_changeset!(%Ecto.Changeset{} = changeset, opts) do
56+
generate_shape_from_changeset(changeset, opts)
57+
end
58+
59+
def shape_from_changeset!(changeset_fun, opts) when is_function(changeset_fun, 1) do
60+
case changeset_fun.(%{}) do
61+
%Ecto.Changeset{} = changeset ->
62+
generate_shape_from_changeset(changeset, opts)
63+
64+
invalid ->
65+
raise ArgumentError,
66+
message:
67+
"Changeset function returned #{inspect(invalid)}, was expecting an Ecto.Changeset struct"
68+
end
69+
end
70+
71+
defp generate_shape_from_changeset(%Ecto.Changeset{data: %schema{}} = changeset, opts) do
72+
if !function_exported?(schema, :__schema__, 1),
73+
do:
74+
raise(ArgumentError,
75+
message:
76+
"cannot generate a shape from a schema-less changeset. Use #{inspect(ShapeDefinition)}.new/2"
77+
)
78+
79+
table_name = schema.__schema__(:source)
80+
namespace = schema.__schema__(:prefix)
81+
pks = schema.__schema__(:primary_key)
82+
83+
columns =
84+
[pks, changeset.required, Keyword.keys(changeset.validations)]
85+
|> Enum.concat()
86+
|> Enum.uniq()
87+
|> Enum.map(&schema.__schema__(:field_source, &1))
88+
|> Enum.map(&to_string/1)
89+
90+
ShapeDefinition.new!(
91+
table_name,
92+
merge_shape_opts(
93+
[columns: columns, parser: {__MODULE__, schema}, namespace: namespace],
94+
opts
95+
)
96+
)
97+
end
98+
99+
defp merge_shape_opts(base, overrides) do
100+
Keyword.merge(base, overrides, fn _key, base, over ->
101+
if is_nil(over), do: base, else: over
102+
end)
103+
end
104+
31105
defp table_name(%{
32106
prefix: query_prefix,
33107
from: %{prefix: source_prefix, source: {table_name, struct}}

packages/elixir-client/lib/electric/client/embedded.ex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ if Code.ensure_loaded?(Electric.Shapes.Api) do
5757
Map.merge(
5858
request.params,
5959
%{
60-
offset: to_string(request.offset),
61-
handle: request.shape_handle,
62-
live: request.live,
63-
replica: request.replica
60+
"offset" => to_string(request.offset),
61+
"handle" => request.shape_handle,
62+
"live" => request.live,
63+
"replica" => request.replica
6464
}
6565
)
6666
end

packages/elixir-client/lib/electric/client/fetch/request.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ defmodule Electric.Client.Fetch.Request do
1313
:shape_handle,
1414
:live,
1515
:next_cursor,
16+
# `replica` does not belong here, it's part of the shape definition that we
17+
# receive via the client params. the other keys in this struct are part
18+
# of consuming a stream rather than defining the shape.
19+
# kept for backwards compatibility
1620
replica: :default,
1721
method: :get,
1822
offset: Client.Offset.before_all(),

0 commit comments

Comments
 (0)