diff --git a/config/test.exs b/config/test.exs index aec90bd..422be69 100644 --- a/config/test.exs +++ b/config/test.exs @@ -11,3 +11,13 @@ config :ecto_sync, TestRepo, pool_size: 30, queue_target: 5000, queue_interval: 5000 + +config :ecto_sync, TestSyncRepo, + username: "postgres", + password: "postgres", + database: "ecto_sync_test_sync", + hostname: System.get_env("DB_HOST", "localhost"), + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: 30, + queue_target: 5000, + queue_interval: 5000 diff --git a/lib/ecto_sync.ex b/lib/ecto_sync.ex index 2eab9be..2fdc0fe 100644 --- a/lib/ecto_sync.ex +++ b/lib/ecto_sync.ex @@ -8,18 +8,19 @@ defmodule EctoSync do @type subscriptions() :: list({EctoWatch.watcher_identifier(), term()}) @type schema_or_list_of_schemas() :: Ecto.Schema.t() | list(Ecto.Schema.t()) @events ~w/inserted updated deleted/a - @cache_name :ecto_sync - @counter_key {__MODULE__, :repo_counters} + @counter_key {EctoSync, :id_counters} defstruct pub_sub: nil, repo: nil, cache_name: nil, watchers: [], - schemas: nil + adapter: nil, + schemas: nil, + ref_counter: nil use Supervisor require Logger - alias EctoSync.{Config, PubSub, Subscriber, Syncer, Watcher} + alias EctoSync.{Options, Subscriber, Syncer, SyncParams, Watcher} alias Ecto.Association.{BelongsTo, Has, ManyToMany} import EctoSync.Helpers @@ -34,36 +35,48 @@ defmodule EctoSync do See `sync/3` for options available. """ def get(event, opts \\ []) do - config = Config.new(event, opts) - Syncer.sync(:cached, config) + sync_params = SyncParams.new(event, opts) + Syncer.sync(:cached, sync_params) end + @doc false def increment_row_ref(keyable) do update_counter(keyable, &(&1 + 1)) end + @doc false + def remove_row_ref(keyable) do + update_counter(keyable, nil) + end + + @doc false + def get_local_id_counters() do + Process.get(@counter_key, %{}) + end + + @doc false + def put_local_id_counters(counters) do + Process.put(@counter_key, counters) + end + @impl true @doc false - def init(state) do - schemas = - state.watchers - |> Enum.map(fn - {%{table_name: table}, _, _} -> - table - - tuple -> - elem(tuple, 0) - end) - |> Enum.uniq() - |> EctoGraph.new() + def init(options) do + global_counter_table = :ets.new(EctoSync.Refs, [:public]) - :persistent_term.put(__MODULE__, %{state | schemas: schemas}) + :persistent_term.put( + __MODULE__, + Map.put(options, :global_counter_table, global_counter_table) + ) children = [ - {Cachex, state.cache_name}, - {Phoenix.PubSub, name: state.pub_sub, adapter: PubSub}, - {Watcher, [repo: state.repo, pub_sub: state.pub_sub, watchers: state.watchers]}, - {Registry, keys: :duplicate, name: EventRegistry} + {Cachex, options.cache_name}, + # {Phoenix.PubSub, name: options.pub_sub, adapter: PubSub}, + # {Watcher, [repo: options.repo, pub_sub: options.pub_sub, watchers: options.watchers]}, + {Registry, keys: :duplicate, name: EventRegistry}, + {EctoSync.Publisher, + watchers: options.watchers, global_counter_table: global_counter_table}, + {options.adapter, options} ] Supervisor.init(children, strategy: :one_for_one) @@ -72,7 +85,7 @@ defmodule EctoSync do @doc """ Determines whether or not the event was sent by this process """ - def should_update?({_schema, _event, {_, ref}} = sync_params) do + def should_update?({schema, event, {%{id: id}, ref}} = sync_params) do current = get_row_ref(sync_params) if is_nil(current) do @@ -84,24 +97,27 @@ defmodule EctoSync do end @doc """ - Starts EctoSync. + Starts EctoSync. ## Options + - `:adapter`, the adapter to use with Postgres, Notify or Wal. - `:cache_name`, the name of the cache that used to cache changes - `:repo`, the repo to track changes in. - `:watchers`, a list of watchers that EctoSync will pass on to EctoWatch. - `:pub_sub`, the PubSub module to use for sending events, defaults to `:ecto_sync_pub_sub`. """ def start_link(opts \\ [name: __MODULE__]) do - state = - %__MODULE__{ - cache_name: opts[:cache_name] || @cache_name, - repo: opts[:repo], - pub_sub: opts[:pub_sub] || :ecto_sync_pub_sub, - watchers: opts[:watchers] - } - - Supervisor.start_link(__MODULE__, state, name: __MODULE__) + case EctoSync.Options.validate(opts) do + {:ok, validated_opts} -> + options = EctoSync.Options.new(validated_opts) + + validate_watcher_uniqueness(options.watchers) + + Supervisor.start_link(__MODULE__, options, name: __MODULE__) + + {:error, errors} -> + raise ArgumentError, "Invalid options: #{Exception.message(errors)}" + end end @doc """ @@ -174,14 +190,16 @@ defmodule EctoSync do def sync(value, sync_params, opts) when is_list(value) or is_struct(value) or is_nil(value) or is_map(value) do if should_update?(sync_params) or opts[:force] do - config = Config.new(sync_params, opts) - Syncer.sync(value, config) + sync_params = + SyncParams.new(sync_params, opts) + + Syncer.sync(value, sync_params) else value end end - def sync(value, _sync_config, _opts), do: value + def sync(value, _sync_params, _opts), do: value @doc """ @@ -209,7 +227,7 @@ defmodule EctoSync do """ @spec unsubscribe(schema_or_list_of_schemas() | Watcher.watcher_identifier(), term()) :: list(term()) - defdelegate unsubscribe(alue, id \\ []), to: Subscriber + defdelegate unsubscribe(value, id \\ []), to: Subscriber @spec watchers(list(), module(), list()) :: list() @doc """ @@ -410,36 +428,117 @@ defmodule EctoSync do defp coerce_to_ref_key({_ecto_schema, _event_or_id} = key), do: key defp coerce_to_ref_key({schema, event, {%{id: id}, _ref}}) do - case event do - :inserted = event -> {schema, event} - _other -> {schema, id} - end + # id + # case event do + # :inserted = event -> {schema, id} + # _other -> {schema, id} + # end + {schema, id} end - defp coerce_to_ref_key(%Ecto.Changeset{data: struct}) do - case struct.__meta__.state do - :loaded -> coerce_to_ref_key(struct) - :built -> {struct, :inserted} - end + defp coerce_to_ref_key(%Ecto.Changeset{data: %ecto_schema{}} = changeset) do + id = + Enum.map(ecto_schema.__schema__(:primary_key), &Ecto.Changeset.get_field(changeset, &1)) + |> Enum.at(0) + + {ecto_schema, id} + # id end defp coerce_to_ref_key(%ecto_schema{} = struct) do - id = Enum.map(ecto_schema.__schema__(:primary_key), &Map.get(struct, &1)) + id = Enum.map(ecto_schema.__schema__(:primary_key), &Map.get(struct, &1)) |> Enum.at(0) {ecto_schema, id} + # id end defp get_row_ref(keyable) do key = coerce_to_ref_key(keyable) - counter_map = Process.get(@counter_key, %{}) + + counter_map = get_local_id_counters() + counter_map[key] end + defp update_counter(keyable, nil) do + key = coerce_to_ref_key(keyable) + + with %{global_counter_table: global_counter_table} <- :persistent_term.get(__MODULE__, nil) do + global_count = :ets.delete(global_counter_table, key) + + updated = + get_local_id_counters() + |> Map.drop([key]) + + put_local_id_counters(updated) + + updated[key] + end + end + defp update_counter(keyable, fun) do key = coerce_to_ref_key(keyable) - counter_map = Process.get(@counter_key, %{}) - updated = Map.update(counter_map, key, 1, fun) - Process.put(@counter_key, updated) - updated[key] + with %{global_counter_table: global_counter_table} <- :persistent_term.get(__MODULE__, nil) do + global_count = + :ets.lookup_element(global_counter_table, key, 2, 0) + + counter_map = + get_local_id_counters() + + updated = + counter_map + |> Map.get(key, global_count) + |> fun.() + |> case do + 0 -> Map.drop(counter_map, [key]) + count -> Map.put(counter_map, key, count) + end + + put_local_id_counters(updated) + + updated[key] + end + end + + defp validate_watcher_uniqueness(watcher_options) do + {without_labels, with_labels} = Enum.split_with(watcher_options, &(&1.label == nil)) + + duplicate_labels = + with_labels + |> Enum.map(& &1.label) + |> duplicate_values() + + duplicate_schema_and_update_types = + without_labels + |> Enum.map(&{&1.schema_definition.label, &1.update_type}) + |> duplicate_values() + + error_messages = + [ + if duplicate_labels != [] do + """ + The following labels are duplicated across watchers: #{Enum.join(duplicate_labels, ", ")} + """ + end, + if duplicate_schema_and_update_types != [] do + """ + The following schema and update type combinations are duplicated across watchers: + + #{Enum.map_join(duplicate_schema_and_update_types, "\n\n ", &inspect/1)} + """ + end + ] + |> Enum.reject(&is_nil/1) + + if error_messages != [] do + raise ArgumentError, Enum.join(error_messages, "\n") + end + end + + defp duplicate_values(values) do + values + |> Enum.group_by(&Function.identity/1) + |> Enum.filter(fn {_, values} -> length(values) >= 2 end) + |> Enum.map(fn {_, [value | _]} -> value end) end end diff --git a/lib/ecto_sync/watcher.ex b/lib/ecto_sync/adapters/postgres/notify.ex similarity index 66% rename from lib/ecto_sync/watcher.ex rename to lib/ecto_sync/adapters/postgres/notify.ex index 0e2897a..d3fdd7e 100644 --- a/lib/ecto_sync/watcher.ex +++ b/lib/ecto_sync/adapters/postgres/notify.ex @@ -1,27 +1,21 @@ # Original code copied and maybe modified from EctoWatch -defmodule EctoSync.Watcher do +defmodule EctoSync.Adapters.Postgres.Notify do @moduledoc """ A library to allow you to easily get notifications about database changes directly from PostgreSQL. """ alias EctoSync.Helpers - alias EctoSync.Watcher.WatcherServer - alias EctoSync.Watcher.WatcherTriggerValidator + + alias EctoSync.Adapters.Postgres.Notify.{ + WatcherServer, + WatcherSupervisor, + WatcherTriggerValidator + } use Supervisor def start_link(opts) do - case EctoSync.Watcher.Options.validate(opts) do - {:ok, validated_opts} -> - options = EctoSync.Watcher.Options.new(validated_opts) - - validate_watcher_uniqueness(options.watchers) - - Supervisor.start_link(__MODULE__, options, name: __MODULE__) - - {:error, errors} -> - raise ArgumentError, "Invalid options: #{Exception.message(errors)}" - end + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) end def init(options) do @@ -35,7 +29,7 @@ defmodule EctoSync.Watcher do children = [ {Postgrex.Notifications, postgrex_notifications_options}, - {EctoSync.Watcher.WatcherSupervisor, options}, + {WatcherSupervisor, options}, {WatcherTriggerValidator, nil} ] @@ -68,27 +62,15 @@ defmodule EctoSync.Watcher do validate_watcher_running!() with :ok <- validate_identifier(watcher_identifier), - {:ok, {pub_sub_mod, channel_name, debug?}} <- + {:ok, {_pub_sub_mod, _channel_name, debug?}} <- WatcherServer.pub_sub_subscription_details(watcher_identifier, id) do if(debug?, do: debug_log(watcher_identifier, "Subscribing to watcher")) - - Phoenix.PubSub.subscribe(pub_sub_mod, channel_name) else {:error, error} -> raise ArgumentError, error end end - @doc """ - Unsubscribe from notifications from watchers that you previously subscribe. It - receives the same params for `subscribe/2`. - - Examples: - - iex> EctoSync.Watcher.unsubscribe({Comment, :updated}) - iex> EctoSync.Watcher.unsubscribe({Comment, :updated}, {:post_id, post_id}) - """ - @spec unsubscribe(watcher_identifier(), term()) :: :ok | {:error, term()} def unsubscribe(watcher_identifier, id \\ nil) do validate_watcher_running!() @@ -169,48 +151,6 @@ defmodule EctoSync.Watcher do end end - defp validate_watcher_uniqueness(watcher_options) do - {without_labels, with_labels} = Enum.split_with(watcher_options, &(&1.label == nil)) - - duplicate_labels = - with_labels - |> Enum.map(& &1.label) - |> duplicate_values() - - duplicate_schema_and_update_types = - without_labels - |> Enum.map(&{&1.schema_definition.label, &1.update_type}) - |> duplicate_values() - - error_messages = - [ - if length(duplicate_labels) > 0 do - """ - The following labels are duplicated across watchers: #{Enum.join(duplicate_labels, ", ")} - """ - end, - if length(duplicate_schema_and_update_types) > 0 do - """ - The following schema and update type combinations are duplicated across watchers: - - #{Enum.map_join(duplicate_schema_and_update_types, "\n\n ", &inspect/1)} - """ - end - ] - |> Enum.reject(&is_nil/1) - - if length(error_messages) > 0 do - raise ArgumentError, Enum.join(error_messages, "\n") - end - end - - defp duplicate_values(values) do - values - |> Enum.group_by(&Function.identity/1) - |> Enum.filter(fn {_, values} -> length(values) >= 2 end) - |> Enum.map(fn {_, [value | _]} -> value end) - end - defp debug_log(watcher_identifier, message) do Helpers.debug_log(watcher_identifier, message) end diff --git a/lib/ecto_sync/watcher/db.ex b/lib/ecto_sync/adapters/postgres/notify/db.ex similarity index 100% rename from lib/ecto_sync/watcher/db.ex rename to lib/ecto_sync/adapters/postgres/notify/db.ex diff --git a/lib/ecto_sync/watcher/watcher_server.ex b/lib/ecto_sync/adapters/postgres/notify/watcher_server.ex similarity index 86% rename from lib/ecto_sync/watcher/watcher_server.ex rename to lib/ecto_sync/adapters/postgres/notify/watcher_server.ex index aedc7e0..c05a5fc 100644 --- a/lib/ecto_sync/watcher/watcher_server.ex +++ b/lib/ecto_sync/adapters/postgres/notify/watcher_server.ex @@ -1,5 +1,5 @@ # Original code copied and maybe modified from EctoWatch -defmodule EctoSync.Watcher.WatcherServer do +defmodule EctoSync.Adapters.Postgres.Notify.WatcherServer do @moduledoc """ Internal GenServer for the individual change watchers which are configured by end users @@ -8,7 +8,7 @@ defmodule EctoSync.Watcher.WatcherServer do alias EctoSync.Watcher.DB alias EctoSync.Helpers - alias EctoSync.Watcher.Options.WatcherOptions + alias EctoSync.Options.WatcherOptions use GenServer @@ -37,12 +37,6 @@ defmodule EctoSync.Watcher.WatcherServer do end end - def broadcast(schema, update_type, values) do - label = :persistent_term.get({EctoSync, {schema, :inserted}}) - {:ok, pid} = find(label) - GenServer.call(pid, {:broadcast, update_type, values}) - end - def start_link({repo_mod, pub_sub_mod, watcher_options}) do GenServer.start_link( __MODULE__, @@ -199,12 +193,6 @@ defmodule EctoSync.Watcher.WatcherServer do {:reply, watcher_details(state), state} end - @impl true - def handle_call({:broadcast, update_type, values}, _from, state) do - do_broadcast(update_type, values, state) - {:reply, :ok, state} - end - defp validate_subscription(state, identifier, column) do cond do match?({_, :inserted}, identifier) && column == state.options.schema_definition.primary_key -> @@ -238,41 +226,11 @@ defmodule EctoSync.Watcher.WatcherServer do %{"type" => type, "values" => returned_values} = Jason.decode!(payload) - returned_values = Map.new(returned_values, fn {k, v} -> {String.to_existing_atom(k), v} end) - type = String.to_existing_atom(type) - do_broadcast(type, returned_values, state) - {:noreply, state} - end - - def do_broadcast(type, values, state) do - message = - case state.options.label do - nil -> - {{state.options.label || state.options.schema_definition.label, type}, values} - label -> - {label, values} - end - - topics = topics(type, state.unique_label, values, state.options.schema_definition) - - new_count = - case Helpers.get_watcher_identifier(elem(message, 0)) do - {_mod, :inserted} = watcher -> watcher - {mod, _} -> {mod, values.id} - label -> {label, values.id} - end - |> EctoSync.increment_row_ref() - - for topic <- topics do - debug_log( - state.options, - "Broadcasting to Phoenix PubSub topic `#{topic}`: #{inspect(message)}" - ) + EctoSync.Publisher.publish(state.options.schema_definition.table_name, type, returned_values) - Phoenix.PubSub.broadcast(state.pub_sub_mod, topic, {message, new_count}) - end + {:noreply, state} end defp watcher_details(%{unique_label: unique_label, repo_mod: repo_mod, options: options}) do @@ -337,7 +295,15 @@ defmodule EctoSync.Watcher.WatcherServer do unique_label | returned_values |> Enum.filter(fn {k, _} -> k in identifier_columns end) - |> Enum.map(fn {k, v} -> "#{unique_label}|#{k}|#{v}" end) + # |> Enum.map(fn {k, v} -> "#{unique_label}|#{k}|#{v}" end) + |> Enum.map(fn {k, v} -> {unique_label, {k, v}} end) + |> then(fn topics -> + if update_type == :inserted do + topics ++ [{unique_label, nil}] + else + topics + end + end) ] end diff --git a/lib/ecto_sync/watcher/watcher_supervisor.ex b/lib/ecto_sync/adapters/postgres/notify/watcher_supervisor.ex similarity index 82% rename from lib/ecto_sync/watcher/watcher_supervisor.ex rename to lib/ecto_sync/adapters/postgres/notify/watcher_supervisor.ex index 61a90eb..fe25f31 100644 --- a/lib/ecto_sync/watcher/watcher_supervisor.ex +++ b/lib/ecto_sync/adapters/postgres/notify/watcher_supervisor.ex @@ -1,12 +1,12 @@ # Original code copied and maybe modified from EctoWatch -defmodule EctoSync.Watcher.WatcherSupervisor do +defmodule EctoSync.Adapters.Postgres.Notify.WatcherSupervisor do @moduledoc """ Internal Supervisor for postgres notification watchers (`EctoSync.Watcher.WatcherServer`) Used internally, but you'll see it in your application supervision tree. """ - alias EctoSync.Watcher.WatcherServer + alias EctoSync.Adapters.Postgres.Notify.WatcherServer use Supervisor @@ -36,7 +36,7 @@ defmodule EctoSync.Watcher.WatcherSupervisor do pid -> {:ok, Supervisor.which_children(pid) - |> Enum.map(fn {_, pid, :worker, [EctoSync.Watcher.WatcherServer]} -> + |> Enum.map(fn {_, pid, :worker, [EctoSync.Adapters.Postgres.Notify.WatcherServer]} -> WatcherServer.details(pid) end)} end diff --git a/lib/ecto_sync/watcher/watcher_trigger_validator.ex b/lib/ecto_sync/adapters/postgres/notify/watcher_trigger_validator.ex similarity index 97% rename from lib/ecto_sync/watcher/watcher_trigger_validator.ex rename to lib/ecto_sync/adapters/postgres/notify/watcher_trigger_validator.ex index 800086d..a7e4930 100644 --- a/lib/ecto_sync/watcher/watcher_trigger_validator.ex +++ b/lib/ecto_sync/adapters/postgres/notify/watcher_trigger_validator.ex @@ -1,5 +1,5 @@ # Original code copied and maybe modified from EctoWatch -defmodule EctoSync.Watcher.WatcherTriggerValidator do +defmodule EctoSync.Adapters.Postgres.Notify.WatcherTriggerValidator do @moduledoc """ Internal task run as part of the EctoSync.Watcher supervision tree to check for a match between the triggers that are in the database and the triggers that were started via the configuration. @@ -7,7 +7,7 @@ defmodule EctoSync.Watcher.WatcherTriggerValidator do Used internally, but you'll see it in your application supervision tree. """ - alias EctoSync.Watcher.WatcherSupervisor + alias EctoSync.Adapters.Postgres.Notify.WatcherSupervisor use Task, restart: :transient diff --git a/lib/ecto_sync/adapters/postgres/wal.ex b/lib/ecto_sync/adapters/postgres/wal.ex new file mode 100644 index 0000000..af0c375 --- /dev/null +++ b/lib/ecto_sync/adapters/postgres/wal.ex @@ -0,0 +1,42 @@ +defmodule EctoSync.Adapters.Postgres.Wal do + use Supervisor + + def start_link(options) do + Supervisor.start_link(__MODULE__, options, name: __MODULE__) + end + + def init(options) do + repo_mod = options.repo_mod + + replication_options = + [publications: :ecto_sync, slot: :ecto_sync] ++ + Keyword.take(repo_mod.config(), ~w/host database username password/a) + + join_modules = Map.keys(options.schemas.join_modules) + + options.watchers + |> Enum.filter(&(&1.schema_definition.label in join_modules)) + |> Enum.each(&setup_indexes(&1, repo_mod)) + + children = [{__MODULE__.Replication, replication_options}] + Supervisor.init(children, strategy: :one_for_one) + end + + defp setup_indexes(%{extra_columns: columns} = watcher, repo_mod) do + table = watcher.schema_definition.table_name + + index_name = "#{table}_#{Enum.join(columns, "_")}_index" + + Ecto.Adapters.SQL.query!( + repo_mod, + "CREATE INDEX IF NOT EXISTS #{index_name} ON #{table} (#{Enum.join(columns, ", ")}); ", + [] + ) + + Ecto.Adapters.SQL.query!( + repo_mod, + "ALTER TABLE #{table} REPLICA IDENTITY USING INDEX #{index_name}; ", + [] + ) + end +end diff --git a/lib/ecto_sync/adapters/postgres/wal/protocol.ex b/lib/ecto_sync/adapters/postgres/wal/protocol.ex new file mode 100644 index 0000000..27069b2 --- /dev/null +++ b/lib/ecto_sync/adapters/postgres/wal/protocol.ex @@ -0,0 +1,70 @@ +defmodule EctoSync.Adapters.Postgres.Wal.Protocol do + import Postgrex.PgOutput.Messages + require Logger + + alias EctoSync.Adapters.Postgres.Wal.Tx + alias Postgrex.PgOutput.Lsn + + @type t :: %__MODULE__{ + tx: term(), + relations: map() + } + + defstruct [ + :tx, + relations: %{} + ] + + @spec new() :: t() + def new do + %__MODULE__{} + end + + def handle_message(msg, state) when is_binary(msg) do + msg + |> decode() + |> handle_message(state) + end + + def handle_message(msg_primary_keep_alive(reply: 0), state), do: {[], nil, state} + + def handle_message(msg_primary_keep_alive(server_wal: lsn, reply: 1), state) do + Logger.debug("msg_primary_keep_alive message reply=true") + <> = Lsn.encode(lsn) + + {[standby_status_update(lsn)], nil, state} + end + + def handle_message(msg, %__MODULE__{tx: nil, relations: relations} = state) do + tx = + [relations: relations, decode: true] + |> Tx.new() + |> Tx.build(msg) + + {[], nil, %{state | tx: tx}} + end + + def handle_message(msg, %__MODULE__{tx: tx} = state) do + case Tx.build(tx, msg) do + %Tx{state: :commit, relations: relations} -> + tx = Tx.finalize(tx) + relations = Map.merge(state.relations, relations) + {[], tx, %{state | tx: nil, relations: relations}} + + tx -> + {[], nil, %{state | tx: tx}} + end + end + + defp standby_status_update(lsn) do + [ + wal_recv: lsn + 1, + wal_flush: lsn + 1, + wal_apply: lsn + 1, + system_clock: now(), + reply: 0 + ] + |> msg_standby_status_update() + |> encode() + end +end diff --git a/lib/ecto_sync/adapters/postgres/wal/replication.ex b/lib/ecto_sync/adapters/postgres/wal/replication.ex new file mode 100644 index 0000000..055f5ad --- /dev/null +++ b/lib/ecto_sync/adapters/postgres/wal/replication.ex @@ -0,0 +1,97 @@ +defmodule EctoSync.Adapters.Postgres.Wal.Replication do + use Postgrex.ReplicationConnection + + alias EctoSync.Adapters.Postgres.Wal.Protocol + + require Logger + + defstruct [ + :publications, + :protocol, + :slot, + :state, + :table_schema_map, + subscribers: %{} + ] + + def start_link(opts) do + conn_opts = [auto_reconnect: true] + publications = opts[:publications] || raise ArgumentError, message: "`:publications` missing" + slot = opts[:slot] || raise ArgumentError, message: "`:slot` missing" + + Postgrex.ReplicationConnection.start_link( + __MODULE__, + {slot, publications}, + conn_opts ++ opts + ) + end + + @impl true + def init({slot, pubs}) do + {:ok, + %__MODULE__{ + slot: slot, + publications: pubs, + protocol: Protocol.new() + }} + end + + @impl true + def handle_connect(%__MODULE__{slot: slot} = state) do + query = """ + CREATE_REPLICATION_SLOT #{slot} TEMPORARY LOGICAL pgoutput NOEXPORT_SNAPSHOT + """ + + Logger.debug("[create slot] query=#{query}") + + {:query, query, %{state | state: :create_slot}} + end + + @impl true + def handle_connect(state) do + {:noreply, state} + end + + @impl true + def handle_result( + [%Postgrex.Result{} | _], + %__MODULE__{state: :create_slot, publications: pubs, slot: slot} = state + ) do + opts = [proto_version: 1, publication_names: pubs] + + query = "START_REPLICATION SLOT #{slot} LOGICAL 0/0 #{escape_options(opts)}" + + Logger.debug("[start streaming] query=#{query}") + + {:stream, query, [], %{state | state: :streaming}} + end + + @impl true + def handle_data(msg, state) do + {return_msgs, tx, protocol} = + Protocol.handle_message(msg, state.protocol) + + if not is_nil(tx) do + Enum.each(tx.operations, fn %{type: type, table: table} = operation -> + EctoSync.Publisher.publish( + table, + type, + (type == :deleted && operation.old_record) || operation.record + ) + end) + end + + {:noreply, return_msgs, %{state | protocol: protocol}} + end + + defp escape_options(opts) do + parts = + Enum.map_intersperse(opts, ", ", fn {k, v} -> [Atom.to_string(k), ?\s, escape_string(v)] end) + + [?\s, ?(, parts, ?)] + end + + defp escape_string(value) do + [?', :binary.replace(to_string(value), "'", "''", [:global]), ?'] + end +end diff --git a/lib/ecto_sync/adapters/postgres/wal/tx.ex b/lib/ecto_sync/adapters/postgres/wal/tx.ex new file mode 100644 index 0000000..8d0814b --- /dev/null +++ b/lib/ecto_sync/adapters/postgres/wal/tx.ex @@ -0,0 +1,75 @@ +defmodule EctoSync.Adapters.Postgres.Wal.Tx do + import Postgrex.PgOutput.Messages + alias Postgrex.PgOutput.Lsn + + alias __MODULE__.Operation + + @type t :: %__MODULE__{ + operations: [Operation.t()], + relations: map(), + timestamp: term(), + xid: pos_integer(), + state: :begin | :commit, + lsn: Lsn.t(), + end_lsn: Lsn.t() + } + + defstruct [ + :timestamp, + :xid, + :lsn, + :end_lsn, + relations: %{}, + operations: [], + state: :begin, + decode: true + ] + + def new(opts \\ []) do + struct(__MODULE__, opts) + end + + @spec finalize(t()) :: t() + def finalize(%__MODULE__{state: :commit, operations: ops} = tx) do + %{tx | operations: Enum.reverse(ops)} + end + + def finalize(%__MODULE__{} = tx), do: tx + + @spec build(t(), tuple()) :: t() + def build(tx, msg_xlog_data(data: data)) do + build(tx, data) + end + + def build(tx, msg_begin(lsn: lsn, timestamp: ts, xid: xid)) do + %{tx | lsn: lsn, timestamp: ts, xid: xid, state: :begin} + end + + def build(%__MODULE__{state: :begin, relations: relations} = tx, msg_relation(id: id) = rel) do + %{tx | relations: Map.put(relations, id, rel)} + end + + def build(%__MODULE__{state: :begin, lsn: tx_lsn} = tx, msg_commit(lsn: lsn, end_lsn: end_lsn)) + when tx_lsn == lsn do + %{tx | state: :commit, end_lsn: end_lsn} + end + + def build(%__MODULE__{state: :begin} = builder, msg_insert(relation_id: id) = msg), + do: build_op(builder, id, msg) + + def build(%__MODULE__{state: :begin} = builder, msg_update(relation_id: id) = msg), + do: build_op(builder, id, msg) + + def build(%__MODULE__{state: :begin} = builder, msg_delete(relation_id: id) = msg), + do: build_op(builder, id, msg) + + # Just skip unknown messages for now + def build(%__MODULE__{} = tx, _msg), do: tx + + defp build_op(%__MODULE__{state: :begin, relations: rels, decode: decode} = tx, id, msg) do + rel = Map.fetch!(rels, id) + op = Operation.from_msg(msg, rel, decode) + + %{tx | operations: [op | tx.operations]} + end +end diff --git a/lib/ecto_sync/adapters/postgres/wal/tx/operation.ex b/lib/ecto_sync/adapters/postgres/wal/tx/operation.ex new file mode 100644 index 0000000..462b171 --- /dev/null +++ b/lib/ecto_sync/adapters/postgres/wal/tx/operation.ex @@ -0,0 +1,98 @@ +defmodule EctoSync.Adapters.Postgres.Wal.Tx.Operation do + @moduledoc """ + Describes a change within a transaction. + The `type` field annotates the change that was persisted to the wal. + + It can be seen as a combination of `msg_relation` and `msg_insert | msg_update | msg_delete` + """ + + # use Access.Struct + + import Postgrex.PgOutput.Messages + alias Postgrex.PgOutput.Type, as: PgType + + @type t :: %__MODULE__{} + defstruct [ + :type, + :schema, + :namespace, + :table, + :record, + :old_record, + :timestamp + ] + + @spec from_msg(tuple(), tuple(), decode :: boolean()) :: t() + def from_msg( + msg_insert(data: data), + msg_relation(columns: columns, namespace: ns, name: name), + decode? + ) do + %__MODULE__{ + type: :inserted, + namespace: ns, + schema: into_schema(columns), + table: name, + record: cast(data, columns, decode?), + old_record: %{} + } + end + + def from_msg( + msg_update(change_data: data, old_data: old_data), + msg_relation(columns: columns, namespace: ns, name: name), + decode? + ) do + %__MODULE__{ + type: :updated, + namespace: ns, + table: name, + schema: into_schema(columns), + record: cast(data, columns, decode?), + old_record: cast(columns, old_data, decode?) + } + end + + def from_msg( + msg_delete(old_data: data), + msg_relation(columns: columns, namespace: ns, name: name), + decode? + ) do + %__MODULE__{ + type: :deleted, + namespace: ns, + schema: into_schema(columns), + table: name, + record: %{}, + old_record: cast(data, columns, decode?) + } + end + + defp into_schema(columns) do + for c <- columns do + c + |> column() + |> Enum.into(%{}) + end + end + + defp cast(data, columns, decode?) do + Enum.zip_reduce([data, columns], %{}, fn [text, typeinfo], acc -> + key = column(typeinfo, :name) + + value = + if decode? do + t = + typeinfo + |> column(:type) + |> PgType.type_info() + + PgType.decode(text, t) + else + text + end + + Map.put(acc, key, value) + end) + end +end diff --git a/lib/ecto_sync/helpers.ex b/lib/ecto_sync/helpers.ex index bc67c03..8e8cff9 100644 --- a/lib/ecto_sync/helpers.ex +++ b/lib/ecto_sync/helpers.ex @@ -2,7 +2,7 @@ defmodule EctoSync.Helpers do @moduledoc false require Logger - alias EctoSync.Config + alias EctoSync.SyncParams def debug_log(watcher_identifier, message) do Logger.debug("EctoSync | #{inspect(watcher_identifier)} | #{inspect(self())} | #{message}") @@ -62,11 +62,8 @@ defmodule EctoSync.Helpers do def get_encoded_label(watcher_identifier), do: :persistent_term.get({EctoSync, watcher_identifier}, watcher_identifier) - def get_watcher_identifier(label), - do: :persistent_term.get({EctoSync, label}, label) - - def get_from_cache(%Config{ - repo: repo, + def get_from_cache(%SyncParams{ + repo_mod: repo, ref: ref, cache_name: cache_name, id: id, @@ -96,11 +93,15 @@ defmodule EctoSync.Helpers do value {:error, error} -> - IO.inspect(error, label: :cachex_error) error end end + def id(%{__struct__: schema_mod} = value) when is_struct(value) do + primary_key(schema_mod) + |> then(&Map.get(value, &1)) + end + def kw_deep_merge([{k1, v1} | list1], [{k1, v1} | list2]) do [{k1, v1} | kw_deep_merge(list1, list2)] end @@ -169,9 +170,10 @@ defmodule EctoSync.Helpers do def nested_sort([{k, v} | rest]), do: [{k, nested_sort(v)} | nested_sort(rest)] def nested_sort(list), do: Enum.sort(list) + def primary_key(%Ecto.Changeset{data: data}) when is_struct(data), do: primary_key(data) + def primary_key(%{__struct__: schema_mod} = value) when is_struct(value) do primary_key(schema_mod) - |> then(&Map.get(value, &1)) end def primary_key(schema_mod) when is_atom(schema_mod) do @@ -222,6 +224,23 @@ defmodule EctoSync.Helpers do end end + def to_struct(schema, data) do + permitted = + data + |> Map.keys() + |> then(fn keys -> + if Enum.any?(keys, &is_binary/1) do + keys + |> Enum.map(&String.to_existing_atom/1) + else + keys + end + end) + + Ecto.Changeset.cast(struct(schema), data, permitted) + |> Ecto.Changeset.apply_changes() + end + def walk_preloaded_assocs(value, acc \\ nil, function) def walk_preloaded_assocs(empty, acc, _function) when is_nil(empty) or empty == [], do: acc diff --git a/lib/ecto_sync/watcher/options.ex b/lib/ecto_sync/options.ex similarity index 66% rename from lib/ecto_sync/watcher/options.ex rename to lib/ecto_sync/options.ex index 48d44a6..f1c3bd0 100644 --- a/lib/ecto_sync/watcher/options.ex +++ b/lib/ecto_sync/options.ex @@ -1,18 +1,36 @@ # Original code copied and maybe modified from EctoWatch -defmodule EctoSync.Watcher.Options do +defmodule EctoSync.Options do @moduledoc false - alias EctoSync.Watcher.Options.WatcherOptions + alias EctoSync.Options.WatcherOptions - defstruct [:repo_mod, :pub_sub_mod, :watchers, :debug?] + @cache_name :ecto_sync + defstruct ~w/adapter cache_name schemas repo_mod pub_sub_mod watchers debug?/a def new(opts) do + watchers = opts[:watchers] + + schemas = + watchers + |> Enum.map(fn + {%{table_name: table}, _, _} -> + table + + tuple -> + elem(tuple, 0) + end) + |> Enum.uniq() + |> EctoGraph.new() + %__MODULE__{ + adapter: opts[:adapter], repo_mod: opts[:repo], pub_sub_mod: opts[:pub_sub], + cache_name: opts[:cache_name] || @cache_name, debug?: opts[:debug?], + schemas: schemas, watchers: - Enum.map(opts[:watchers], fn watcher_opts -> + Enum.map(watchers, fn watcher_opts -> WatcherOptions.new(watcher_opts, opts[:debug?]) end) } @@ -24,14 +42,11 @@ defmodule EctoSync.Watcher.Options do type: {:custom, __MODULE__, :check_valid_repo_module, []}, required: true ], - pub_sub: [ - type: {:custom, __MODULE__, :check_valid_pubsub_module, []}, - required: true - ], watchers: [ type: {:custom, WatcherOptions, :validate_list, []}, required: true ], + adapter: [type: :atom, required: true], debug?: [ type: :boolean, required: false, @@ -39,7 +54,7 @@ defmodule EctoSync.Watcher.Options do ] ] - NimbleOptions.validate(opts, schema) + NimbleOptions.validate(opts, NimbleOptions.new!(schema)) end def check_valid_repo_module(repo_mod) when is_atom(repo_mod) do diff --git a/lib/ecto_sync/watcher/options/watcher_options.ex b/lib/ecto_sync/options/watcher_options.ex similarity index 98% rename from lib/ecto_sync/watcher/options/watcher_options.ex rename to lib/ecto_sync/options/watcher_options.ex index 0bf41df..b1c8eeb 100644 --- a/lib/ecto_sync/watcher/options/watcher_options.ex +++ b/lib/ecto_sync/options/watcher_options.ex @@ -1,5 +1,5 @@ # Original code copied and maybe modified from EctoWatch -defmodule EctoSync.Watcher.Options.WatcherOptions do +defmodule EctoSync.Options.WatcherOptions do @moduledoc false alias EctoSync.Helpers @@ -78,6 +78,8 @@ defmodule EctoSync.Watcher.Options.WatcherOptions do end end + def validate(%__MODULE__{} = watcher_options), do: {:ok, watcher_options} + def validate({schema_definition, update_type}) do validate({schema_definition, update_type, []}) end diff --git a/lib/ecto_sync/pub_sub.ex b/lib/ecto_sync/pub_sub.ex deleted file mode 100644 index 524216b..0000000 --- a/lib/ecto_sync/pub_sub.ex +++ /dev/null @@ -1,43 +0,0 @@ -defmodule EctoSync.PubSub do - @moduledoc false - @behaviour Phoenix.PubSub.Adapter - - use Supervisor - - alias Phoenix.PubSub.PG2 - - @impl true - def node_name(_), do: node() - - @impl true - def broadcast(adapter_name, topic, {{schema_event, identifiers}, ref}, _dispatcher) do - message = - case :persistent_term.get({EctoSync, schema_event}, schema_event) do - {schema, event} -> - {schema, event, {identifiers, ref}} - # label -> {label, event, {identifiers, ref}} - end - - pubsub = - Module.split(adapter_name) - |> Enum.at(0) - |> String.to_existing_atom() - - Registry.dispatch(pubsub, topic, fn entries -> - for {pid, _} <- entries do - send(pid, {EctoSync, message}) - end - end) - - {:error, :already_dispatched} - end - - @impl true - defdelegate direct_broadcast(adapter_name, node_name, topic, message, dispatcher), to: PG2 - - # @impl true - defdelegate start_link(opts), to: PG2 - - @impl true - defdelegate init(args), to: PG2 -end diff --git a/lib/ecto_sync/publisher.ex b/lib/ecto_sync/publisher.ex new file mode 100644 index 0000000..95edd36 --- /dev/null +++ b/lib/ecto_sync/publisher.ex @@ -0,0 +1,84 @@ +defmodule EctoSync.Publisher do + import EctoSync.Helpers, only: [ecto_schema_mod?: 1] + require Logger + use GenServer + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def init(opts) do + watchers = opts[:watchers] + global_counter_table = opts[:global_counter_table] + {:ok, %{watchers: watchers, global_counter_table: global_counter_table}} + end + + def publish(schema_or_table, event, values) do + table = + if ecto_schema_mod?(schema_or_table) do + schema_or_table.__schema__(:source) + else + schema_or_table + end + + GenServer.cast(__MODULE__, {:publish, table, event, values}) + end + + def handle_cast( + {:publish, table, event, values}, + %{global_counter_table: global_counter_table} = state + ) do + %{ + schema_definition: %{primary_key: primary_key} = schema_definition, + extra_columns: extra_columns + } = + Enum.find(state.watchers, &(&1.schema_definition.table_name == table)) + + schema = + if is_binary(schema_definition.label) do + schema_definition.table_name + else + schema_definition.label + end + + identifiers = + Map.take(values, ([primary_key] ++ extra_columns) |> Enum.map(&to_string/1)) + |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) + + id = Map.get(identifiers, primary_key) + + ref = :ets.update_counter(global_counter_table, id, 1, {id, 0}) + + if event == :inserted do + [nil] + else + [primary_key] + end + |> Enum.concat(extra_columns) + |> Enum.map(fn field -> + identifier = + (field && {field, Map.get(identifiers, field)}) || + nil + + EctoSync.Subscriber.subscriptions({schema, event}, identifier) + |> Enum.map(fn {pid, opts} -> + case opts[:parent] do + {key, id} -> + # The has_many assoc has moved away from this subscription + if identifiers[key] != id do + # send(pid, {:ecto_sync, {schema, :inserted, {identifier, ref}}}) + Logger.debug("publishing insert to #{inspect(pid)}") + publish(table, :inserted, values) + end + + _ -> + nil + end + + send(pid, {:ecto_sync, {schema, event, {identifiers, ref}}}) + end) + end) + + {:noreply, state} + end +end diff --git a/lib/ecto_sync/repo.ex b/lib/ecto_sync/repo.ex index db47160..5c3ef03 100644 --- a/lib/ecto_sync/repo.ex +++ b/lib/ecto_sync/repo.ex @@ -9,6 +9,7 @@ defmodule EctoSync.Repo do otp_app: :my_app end """ + alias Ecto.Changeset defmacro __using__(opts) do quote do @@ -37,34 +38,26 @@ defmodule EctoSync.Repo do defoverridable [{unquote(fun), unquote(arity)}] def unquote(fun)(unquote_splicing(args)) do - unquote(__MODULE__).__capture_op__( - unquote(fun), - unquote(args), - &EctoSync.increment_row_ref/1 - ) + repo = get_dynamic_repo() + [_value, opts] = unquote(args) + tuplet = Ecto.Repo.Supervisor.tuplet(repo, prepare_opts(unquote(fun), opts)) + + value = unquote(__MODULE__).__handle__(unquote(fun), unquote(args), tuplet) try do - case super(unquote_splicing(args)) do + case super(value, opts) do {:error, _} = result -> throw({:operation_failed, result}) result -> result end rescue error -> - unquote(__MODULE__).__capture_op__( - unquote(fun), - unquote(args), - &EctoSync.increment_row_ref/1 - ) + unquote(__MODULE__).__handle__({:failed, unquote(fun)}, unquote(args), tuplet) reraise error, __STACKTRACE__ end catch {:operation_failed, result} -> - unquote(__MODULE__).__capture_op__( - unquote(fun), - unquote(args), - &EctoSync.increment_row_ref/1 - ) + unquote(__MODULE__).__handle__({:failed, unquote(fun)}, unquote(args), {}) result end @@ -72,22 +65,57 @@ defmodule EctoSync.Repo do end end - @doc false - def __capture_op__(fun_name, args, ecto_sync_fun) do - key = - case {fun_name, args} do - {insert, [struct, _opts]} when insert in ~w/insert insert!/a -> - {struct, :inserted} + def __handle__(insert, [value | _args], {adapter_meta, _opts}) + when insert in ~w/insert insert!/a do + %{adapter: adapter} = adapter_meta - {_op, [struct_or_changeset, _opts]} -> - struct_or_changeset + with {key, _source, :binary_id = type} <- binary_id(value) do + if dump_value = Ecto.Type.adapter_autogenerate(adapter, type) do + {:ok, cast_value} = Ecto.Type.adapter_load(adapter, type, dump_value) - _ -> - nil - end + prepared = + case value do + %Changeset{changes: changes} = changeset -> + %{ + changeset + | changes: Map.put_new(changes, key, cast_value) + } + + _ when is_map(value) -> + Map.put_new(value, key, cast_value) + end - unless is_nil(key) do - ecto_sync_fun.(key) + EctoSync.increment_row_ref(prepared) + prepared + end + else + _ -> + value end end + + @doc false + def __handle__(delete, [value | _args], _tuplet) when delete in ~w/delete delete!/a do + value + end + + @doc false + def __handle__(fun_name, [value | _args], _tuplet) when fun_name in ~w/update update!/a do + EctoSync.increment_row_ref(value) + value + end + + @doc false + def __handle__({:failed, _fun}, [value | _args], _tuplet) do + EctoSync.decrement_row_ref(value) + value + end + + defp binary_id(%Changeset{data: data}), do: binary_id(data) + + defp binary_id(%schema_mod{}) do + schema_mod.__schema__(:autogenerate_id) + end + + defp binary_id(_other), do: false end diff --git a/lib/ecto_sync/subscriber.ex b/lib/ecto_sync/subscriber.ex index 1309664..c2e1a5c 100644 --- a/lib/ecto_sync/subscriber.ex +++ b/lib/ecto_sync/subscriber.ex @@ -3,13 +3,13 @@ defmodule EctoSync.Subscriber do require Logger import EctoSync.Helpers - alias EctoSync.Watcher alias Ecto.Association alias Ecto.Association.{BelongsTo, Has, HasThrough, ManyToMany} + @type watcher_identifier() :: {atom(), atom()} | atom() @events ~w/inserted updated deleted/a - def subscribe(watcher_identifier_or_struct, id \\ nil) + def subscribe(watcher_identifier_or_struct, opts \\ []) def subscribe(values, opts) when is_list(values) do values @@ -22,9 +22,14 @@ defmodule EctoSync.Subscriber do end) end - def subscribe(schema_mod, event) + def subscribe({schema_mod, event} = watcher_identifier, opts) when is_atom(schema_mod) and is_atom(event) and not is_nil(event) do - [do_subscribe({schema_mod, event}, nil, [])] + watcher_identifier + |> subscribe_events() + |> add_opts(opts) + |> Enum.map(fn {{watcher_identifier, id}, opts} -> + do_subscribe(watcher_identifier, id, opts) + end) end def subscribe([value | _] = list, opts) when is_struct(value), @@ -48,23 +53,37 @@ defmodule EctoSync.Subscriber do |> Enum.uniq() end - def subscribe(watcher_identifier, id) do - Enum.map(subscribe_events(watcher_identifier, id), &do_subscribe(&1, id, [])) + def subscribe(watcher_identifier, id) when is_binary(id) or is_number(id) do + watcher_identifier + |> subscribe_events(id) + |> Enum.map(&do_subscribe(&1, id, [])) + end + + def subscribe(label, []) when is_atom(label) do + label + |> subscribe_events(label) + |> Enum.map(&do_subscribe(&1, nil, preloads: [])) end defp do_subscribe(watcher_identifier, id, opts) do - encoded_identifier = get_encoded_label(watcher_identifier) + encoded_identifier = + get_encoded_label(watcher_identifier) pids = subscriptions(watcher_identifier, id) |> Enum.map(&elem(&1, 0)) if self() not in pids do - Logger.debug("EventRegistry | #{inspect({watcher_identifier, id, opts})}") + # Logger.debug("EventRegistry | #{inspect({watcher_identifier, id, opts})}") + Logger.debug("EventRegistryRegister | #{inspect({encoded_identifier, id, opts})}") - Registry.register(EventRegistry, {encoded_identifier, id}, opts) + Registry.register( + EventRegistry, + {encoded_identifier, id}, + opts + ) - Watcher.subscribe(encoded_identifier, id) + # Watcher.subscribe(encoded_identifier, id) end {watcher_identifier, id} @@ -72,7 +91,6 @@ defmodule EctoSync.Subscriber do def subscriptions(watcher_identifier, id) do encoded = get_encoded_label(watcher_identifier) - Registry.lookup(EventRegistry, {encoded, id}) end @@ -85,10 +103,9 @@ defmodule EctoSync.Subscriber do end def subscribe_events(struct, %Has{related_key: related_key, related: schema, field: field}) do - parent_id = primary_key(struct) + parent_id = id(struct) assoc_field = {related_key, parent_id} assocs = Map.get(struct, field) - # | subscribe_events(struct) [{{schema, :inserted}, assoc_field}] ++ [Enum.map(assocs, &subscribe_events/1)] end @@ -107,7 +124,7 @@ defmodule EctoSync.Subscriber do join_through: join_through, join_keys: [{parent_key, _} | _] }) do - id = primary_key(struct) + id = id(struct) Enum.map(@events, &{{join_through, &1}, {parent_key, id}}) end @@ -125,11 +142,11 @@ defmodule EctoSync.Subscriber do end def subscribe_events(%schema{} = value, _) when is_struct(value) do - id = primary_key(value) + id = id(value) if ecto_schema_mod?(schema) do ~w/updated deleted/a - |> Enum.map(&{{schema, &1}, id}) + |> Enum.map(&{{schema, &1}, {primary_key(schema), id}}) else end end @@ -138,10 +155,13 @@ defmodule EctoSync.Subscriber do when is_atom(schema) and event in [:all | @events] do case watcher_identifier do {schema, :all} -> - Enum.map(@events, &{{schema, &1}, id}) + Enum.map(@events, &{{schema, &1}, {primary_key(schema), id}}) + + {_, :inserted} -> + [{watcher_identifier, nil}] - _ -> - List.wrap(watcher_identifier) + {_schema, _event} -> + [{watcher_identifier, id}] end end @@ -153,6 +173,16 @@ defmodule EctoSync.Subscriber do self() in pids end + @doc """ + Unsubscribe from notifications from watchers that you previously subscribe. It + receives the same params for `subscribe/2`. + + Examples: + + iex> EctoSync.Watcher.unsubscribe({Comment, :updated}) + iex> EctoSync.Watcher.unsubscribe({Comment, :updated}, {:post_id, post_id}) + """ + @spec unsubscribe(watcher_identifier(), term()) :: :ok | {:error, term()} def unsubscribe(value, opts \\ []) def unsubscribe(watcher_identifier, id) when is_binary(watcher_identifier) do @@ -164,17 +194,17 @@ defmodule EctoSync.Subscriber do when is_tuple(watcher_identifier) or is_atom(watcher_identifier) do id = (is_list(id) && nil) || id - try do - encoded_identifier = get_encoded_label(watcher_identifier) + # try do + encoded_identifier = get_encoded_label(watcher_identifier) - case Watcher.unsubscribe(encoded_identifier, id) do - :ok -> Registry.unregister(EventRegistry, {encoded_identifier, id}) - error -> error - end - catch - ArgumentError -> - raise ArgumentError, "no watcher found for #{inspect(watcher_identifier)}" - end + # case Watcher.unsubscribe(encoded_identifier, id) do + Registry.unregister(EventRegistry, {encoded_identifier, id}) + # error -> error + # end + # catch + # ArgumentError -> + # raise ArgumentError, "no watcher found for #{inspect(watcher_identifier)}" + # end end def unsubscribe([value | _] = values, opts) when is_struct(value) do @@ -256,11 +286,20 @@ defmodule EctoSync.Subscriber do {related, related_key} end - ([{{related, :inserted}, {related_key, primary_key(parent)}}] + ([{{related, :inserted}, {related_key, id(parent)}}] |> Enum.map(&add_opts(&1, opts))) ++ acc value -> + opts = + case assoc_info do + %Association.Has{related_key: related_key} -> + [parent: {related_key, id(parent)}] ++ opts + + _ -> + opts + end + events = subscribe_events(parent, assoc_info) |> add_opts(opts) diff --git a/lib/ecto_sync/config.ex b/lib/ecto_sync/sync_params.ex similarity index 59% rename from lib/ecto_sync/config.ex rename to lib/ecto_sync/sync_params.ex index 5713e3f..677662e 100644 --- a/lib/ecto_sync/config.ex +++ b/lib/ecto_sync/sync_params.ex @@ -1,7 +1,7 @@ -defmodule EctoSync.Config do +defmodule EctoSync.SyncParams do @moduledoc false - @derive {Inspect, only: ~w/id ref schema event/a} + @derive {Inspect, only: ~w/id ref schema event assocs/a} alias EctoSync.Helpers import Ecto.Query @@ -15,14 +15,15 @@ defmodule EctoSync.Config do preloads: [], pub_sub: nil, ref: nil, - repo: nil, - schema: nil + repo_mod: nil, + schema: nil, + strict: false def new({label, {identifiers, ref}}, opts) when is_atom(label) do - {config, state} = init(identifiers, ref, opts) + {sync_params, options} = init(identifiers, ref, opts) {%{table_name: table, primary_key: primary_key, columns: columns}, event, _} = - state.watchers + options.watchers |> Enum.find(fn {_, _, opts} -> Keyword.get(opts, :label) == label @@ -36,7 +37,7 @@ defmodule EctoSync.Config do keys = [primary_key | columns] %{ - config + sync_params | schema: table, event: event, get_fun: fn table, id -> @@ -45,32 +46,34 @@ defmodule EctoSync.Config do from(table) |> select([t], ^keys) |> where(^filters) - |> config.repo.one + |> sync_params.repo_mod.one end } end def new({schema, event, {identifiers, ref}}, opts) do - {config, _} = init(identifiers, ref, opts) - %{config | schema: schema, event: event, get_fun: &config.repo.get(&1, &2)} + {sync_params, _} = init(identifiers, ref, opts) + %{sync_params | schema: schema, event: event, get_fun: &sync_params.repo_mod.get(&1, &2)} end defp init(%{id: id} = identifiers, ref, opts) do assocs = Map.drop(identifiers, [:id]) - state = :persistent_term.get(EctoSync) + options = :persistent_term.get(EctoSync) - global = Map.take(state, ~w/cache_name schemas pub_sub repo/a) + options = + Map.take(options, ~w/cache_name schemas pub_sub repo_mod/a) {%__MODULE__{ id: id, ref: ref, assocs: assocs, - preloads: (opts[:preloads] || %{}) |> Helpers.normalize_to_preloads() + preloads: (opts[:preloads] || %{}) |> Helpers.normalize_to_preloads(), + strict: opts[:strict] || false } - |> Map.merge(global), state} + |> Map.merge(options), options} end - def maybe_put_get_fun(config, nil), do: config - def maybe_put_get_fun(config, get_fun), do: Map.put(config, :get_fun, get_fun) + def maybe_put_get_fun(sync_params, nil), do: sync_params + def maybe_put_get_fun(sync_params, get_fun), do: Map.put(sync_params, :get_fun, get_fun) end diff --git a/lib/ecto_sync/syncer.ex b/lib/ecto_sync/syncer.ex index 402d987..643f3c8 100644 --- a/lib/ecto_sync/syncer.ex +++ b/lib/ecto_sync/syncer.ex @@ -1,128 +1,165 @@ defmodule EctoSync.Syncer do @moduledoc false - alias EctoSync.{Config, Subscriber} + alias EctoSync.{SyncParams, Subscriber} alias Ecto.Association.{BelongsTo, Has, HasThrough, ManyToMany, NotLoaded} import EctoSync.Helpers - import Ecto.Query - def sync(from_cache_or_value, config) + def sync(from_cache_or_value, params) - def sync(:cached, %{event: :deleted} = config) do - do_unsubscribe(config) + def sync(:cached, %{event: :deleted} = params) do + do_unsubscribe(params) + + if is_binary(params.schema) do + {params.schema, params.id} + else + struct(params.schema, %{id: params.id}) + end end - def sync(:cached, %{event: :inserted} = config) do - value = get_from_cache(config) + def sync(:cached, %{event: :inserted} = params) do + value = get_from_cache(params) EctoSync.subscribe(value) value end - def sync(:cached, config), do: get_from_cache(config) + def sync(:cached, params), do: get_from_cache(params) - def sync(value_or_values, %{event: :deleted} = config) do - do_unsubscribe(config) - do_sync(value_or_values, config.id, config) + def sync(value_or_values, %{event: :deleted} = params) do + do_unsubscribe(params) + do_sync(value_or_values, params.id, params) end - def sync(value_or_values, %{schema: schema, event: :inserted} = config) do + def sync(value_or_values, %{schema: schema, event: :inserted} = params) do preloads = - for id <- config.assocs, - {_, [assocs: assoc]} <- Subscriber.subscriptions({schema, :inserted}, id) do + for id <- params.assocs, + {_, opts} <- Subscriber.subscriptions({schema, :inserted}, id), + assoc <- opts[:assocs] do assoc end - |> Enum.concat(config.preloads[schema] || []) + |> Enum.concat(params.preloads[schema] || []) |> List.flatten() - config = + params = %{ - config - | preloads: Map.update(config.preloads, schema, preloads, &kw_deep_merge(&1, preloads)) + params + | preloads: Map.update(params.preloads, schema, preloads, &kw_deep_merge(&1, preloads)) } if is_binary(schema) do - case Map.get(config.schemas.join_modules, schema) do + case Map.get(params.schemas.join_modules, schema) do associated_schemas -> associated_schemas |> Enum.reduce(value_or_values, fn {_parent, {key, child}}, acc -> - id = config.assocs[key] + id = params.assocs[key] record = - get_preloaded(child, id, preloads, config) + get_preloaded(child, id, preloads, params) Subscriber.subscribe(record, assocs: preloads) - do_sync(acc, record, config) + do_sync(acc, record, params) end) end else - new = get_preloaded(config.schema, config.id, preloads, config) + new = get_preloaded(params.schema, params.id, preloads, params) Subscriber.subscribe(new, assocs: preloads) - do_sync(value_or_values, new, config) + do_sync(value_or_values, new, params) |> then(fn values when is_list(values) -> - Enum.map(values, &maybe_update_has_through(&1, new, config)) + Enum.map(values, &maybe_update_has_through(&1, new, params)) value -> - maybe_update_has_through(value, new, config) + maybe_update_has_through(value, new, params) end) end end - def sync(value_or_values, config) do - new = get_from_cache(config) + def sync(value_or_values, params) do + new = get_from_cache(params) - do_sync(value_or_values, new, config) + do_sync(value_or_values, new, params) end defp do_sync(nil, new, %{event: :inserted}), do: new - defp do_sync([], new, %{event: :inserted}) do + defp do_sync([], new, %{event: event}) when event in ~w/inserted updated/a do [new] end - defp do_sync([%schema{} | _] = values, new, %{event: :inserted, schema: schema} = config) do - Enum.map(values, &do_sync(&1, new, config)) ++ [new] + defp do_sync( + [%schema{} | _] = values, + new, + %{event: :inserted, schema: schema, strict: true} = params + ) do + Enum.map(values, &do_sync(&1, new, params)) ++ [new] end - defp do_sync([%schema{} | _] = values, id, %{event: :deleted, schema: schema} = config) do + defp do_sync([%_schema{} | _] = values, new, %{event: :inserted, strict: false} = params) do + Enum.map(values, &do_sync(&1, new, params)) ++ [new] + end + + defp do_sync([%schema{} | _] = values, id, %{event: :deleted, schema: schema} = params) do Enum.reject(values, &same_record?(&1, {schema, id})) - |> Enum.map(&do_sync(&1, id, config)) + |> Enum.map(&do_sync(&1, id, params)) end - defp do_sync(values, new, config) when is_list(values), - do: Enum.map(values, &do_sync(&1, new, config)) + defp do_sync(values, new, params) when is_list(values), + do: Enum.map(values, &do_sync(&1, new, params)) + + defp do_sync(%value_schema{} = value, deleted_id, %{event: :deleted, schema: schema} = params) do + case Map.get(params.schemas.join_modules, schema) do + nil -> + params.schemas + |> EctoGraph.paths(value_schema, schema) + |> EctoGraph.prewalk(value, &assoc_update(&1, &2, &3, deleted_id, params)) - defp do_sync(%value_schema{} = value, %new_schema{} = new, config) when is_struct(value) do + associated_schemas -> + associated_schemas + |> Enum.reduce(value, fn {parent, {key, child}}, acc -> + id = params.assocs[key] + + params.schemas + |> EctoGraph.paths(value_schema, parent) + |> EctoGraph.prewalk(acc, fn _acc, assoc, _assoc_info -> + params.schemas + |> EctoGraph.paths(parent, child) + |> EctoGraph.prewalk(assoc, &assoc_update(&1, &2, &3, id, params)) + end) + end) + end + end + + defp do_sync(%value_schema{} = value, %new_schema{} = new, params) when is_struct(value) do if same_record?(value, new) do - preloads = find_preloads(config.preloads[new_schema] || value) + preloads = find_preloads(params.preloads[new_schema] || value) - get_preloaded(value_schema, config.id, preloads, config) + get_preloaded(value_schema, params.id, preloads, params) else - config.schemas + params.schemas |> EctoGraph.paths(value_schema, new_schema) - |> EctoGraph.prewalk(value, &assoc_update(&1, &2, &3, new, config)) + |> EctoGraph.prewalk(value, &assoc_update(&1, &2, &3, new, params)) end end - defp do_sync(%value_schema{} = value, new, %{schema: schema} = config) do - case Map.get(config.schemas.join_modules, schema) do + defp do_sync(%value_schema{} = value, new, %{schema: schema} = params) do + case Map.get(params.schemas.join_modules, schema) do nil -> - config.schemas + params.schemas |> EctoGraph.paths(value_schema, schema) - |> EctoGraph.prewalk(value, &assoc_update(&1, &2, &3, new, config)) + |> EctoGraph.prewalk(value, &assoc_update(&1, &2, &3, new, params)) associated_schemas -> associated_schemas |> Enum.reduce(value, fn {_parent, {key, child}}, acc -> - id = config.assocs[key] - record = get_preloaded(child, id, [], config) - do_sync(acc, record, config) + id = params.assocs[key] + record = get_preloaded(child, id, [], params) + do_sync(acc, record, params) end) end end - defp do_sync(value, _new, _config) do + defp do_sync(value, _new, _params) do value end @@ -135,9 +172,9 @@ defmodule EctoSync.Syncer do join_keys: [_, {child_key, _}] }, _, - %{schema: schema, event: :deleted} = config + %{schema: schema, event: :deleted} = params ) do - id = Map.get(config.assocs || %{}, child_key) + id = Map.get(params.assocs || %{}, child_key) case find_by_primary_key(assoc, {related_schema, id}) do nil -> assoc @@ -182,7 +219,7 @@ defmodule EctoSync.Syncer do end end - defp assoc_update(value, assocs, %Has{} = assoc_info, new, %{schema: schema} = config) do + defp assoc_update(value, assocs, %Has{} = assoc_info, new, %{schema: schema} = params) do possible_index = find_by_primary_key(assocs, new) related_id = Map.get(new, assoc_info.related_key) owner_id = Map.get(value, assoc_info.owner_key) @@ -194,63 +231,55 @@ defmodule EctoSync.Syncer do # Broadcast an insert to the new owner # TODO Unsubscribe from the assoc. - if not EctoSync.subscribed?({schema, :inserted}, {assoc_info.related_key, related_id}) do - do_unsubscribe(config) - end - - EctoSync.Watcher.WatcherServer.broadcast( - schema, - :inserted, - %{:id => new.id, assoc_info.related_key => related_id} - ) + do_unsubscribe(params) List.delete_at(assocs, possible_index) # Maybe we are assigned as assoc is_nil(possible_index) and related_id == owner_id and assoc_info.related == schema -> - do_insert(assocs, new, assoc_info, config) + do_insert(assocs, new, assoc_info, params) true -> - maybe_update(assocs, new, config) + maybe_update(assocs, new, params) end end - defp assoc_update(value, assoc, assoc_info, new, config) do - {related?, resolved} = resolve_assoc(assoc_info, value, new, config) + defp assoc_update(value, assoc, assoc_info, new, params) do + {related?, resolved} = resolve_assoc(assoc_info, value, new, params) - if related? and config.event == :inserted do - do_insert(assoc, resolved, assoc_info, config) + if related? and params.event == :inserted do + do_insert(assoc, resolved, assoc_info, params) else - maybe_update(assoc, new, config) + maybe_update(assoc, new, params) end end - defp maybe_update(values, new, config) when is_list(values), - do: Enum.map(values, &maybe_update(&1, new, config)) |> Enum.reject(&is_nil/1) + defp maybe_update(values, new, params) when is_list(values), + do: Enum.map(values, &maybe_update(&1, new, params)) |> Enum.reject(&is_nil/1) - defp maybe_update(%schema{} = value, new, config) do + defp maybe_update(%schema{} = value, new, params) do if same_record?(value, new) do if value != new do new end - preloads = find_preloads(config.preloads[new.__struct__] || value) + preloads = find_preloads(params.preloads[new.__struct__] || value) - get_preloaded(schema, new.id, preloads, config) + get_preloaded(schema, new.id, preloads, params) else value end end - defp maybe_update_has_through(%value_schema{} = value, %new_schema{} = new, config) do + defp maybe_update_has_through(%value_schema{} = value, %new_schema{} = new, params) do # For each preloaded assoc, check if there is another schema that has it as a HasThrough. # If so, update that association based on its path for the assoc. reduce_preloaded_assocs(value, fn {key, %HasThrough{through: through} = assoc_info}, acc -> related_schema = resolve_through(value_schema, through) - config.schemas + params.schemas |> EctoGraph.paths(new_schema, related_schema) |> Enum.map(&EctoGraph.get(new, &1)) |> Enum.reduce(acc, fn values, acc -> @@ -259,7 +288,7 @@ defmodule EctoSync.Syncer do acc value, acc -> - Map.update!(acc, key, &do_insert(&1, value, assoc_info, config)) + Map.update!(acc, key, &do_insert(&1, value, assoc_info, params)) end) end) @@ -268,14 +297,14 @@ defmodule EctoSync.Syncer do end) end - defp do_insert(assocs, {_new, {schema, id}}, assoc_info, config) when is_list(assocs) do + defp do_insert(assocs, {_new, {schema, id}}, assoc_info, params) when is_list(assocs) do preloads = case assocs do - [] -> config.preloads[schema] || [] - _ -> find_preloads(config.preloads[schema] || assocs || []) + [] -> params.preloads[schema] || [] + _ -> find_preloads(params.preloads[schema] || assocs || []) end - inserted = get_preloaded(schema, id, preloads, config) + inserted = get_preloaded(schema, id, preloads, params) in_where = match_where?(inserted, assoc_info) if in_where do @@ -286,43 +315,43 @@ defmodule EctoSync.Syncer do end end - defp do_insert(assocs, %schema{} = new, assoc_info, config) when is_list(assocs) do + defp do_insert(assocs, %schema{} = new, assoc_info, params) when is_list(assocs) do preloads = case assocs do - [] -> config.preloads[schema] || [] - _ -> find_preloads(config.preloads[schema] || assocs || []) + [] -> params.preloads[schema] || [] + _ -> find_preloads(params.preloads[schema] || assocs || []) end - inserted = get_preloaded(schema, new.id, preloads, config) + inserted = get_preloaded(schema, new.id, preloads, params) in_where = match_where?(inserted, assoc_info) if in_where do EctoSync.subscribe(inserted) - Enum.map(assocs, &maybe_update(&1, new, config)) ++ [inserted] + Enum.map(assocs, &maybe_update(&1, new, params)) ++ [inserted] else assocs end end - defp do_insert(assoc, %schema{} = new, assoc_info, config) do - preloads = find_preloads(config.preloads[schema] || assoc) + defp do_insert(assoc, %schema{} = new, assoc_info, params) do + preloads = find_preloads(params.preloads[schema] || assoc) - new = get_preloaded(schema, new.id, preloads, config) + new = get_preloaded(schema, new.id, preloads, params) (match_where?(new, assoc_info) && new) || assoc end - defp get_preloaded(schema, id, preloads, config) do - repo = config.repo + defp get_preloaded(schema, id, preloads, params) do + repo = params.repo_mod - config = - Config.maybe_put_get_fun(config, fn schema, id -> - from(schema, where: [id: ^id]) |> repo.one() |> repo.preload(preloads, force: true) + params = + SyncParams.maybe_put_get_fun(params, fn schema, id -> + repo.get(schema, id) |> repo.preload(preloads, force: true) end) - get_from_cache(%{config | schema: schema, id: id, preloads: %{schema => preloads}}) + get_from_cache(%{params | schema: schema, id: id, preloads: %{schema => preloads}}) end defp resolve_assoc(%ManyToMany{join_through: schema} = assoc, value, new, %{schema: schema}) diff --git a/mix.exs b/mix.exs index 586c254..6574778 100644 --- a/mix.exs +++ b/mix.exs @@ -67,13 +67,23 @@ defmodule EctoSync.MixProject do {:phoenix_pubsub, ">= 1.0.0"}, {:jason, ">= 1.0.0"}, {:ecto_graph, "~> 0.2.0"}, + {:ecto, "~> 3.14"}, {:ecto_sql, ">= 3.0.0"}, {:mix_test_watch, "~> 1.0", only: [:dev, :test]}, + {:postgrex_pgoutput, "~> 0.2.0"}, {:mox, "~> 1.2", only: [:dev, :test]} ] end defp aliases do - [test: ["ecto.create --quiet -r TestRepo", "ecto.migrate --quiet -r TestRepo", "test"]] + [ + test: [ + "ecto.create --quiet -r TestRepo", + "ecto.create -r TestSyncRepo", + "ecto.migrate --quiet -r TestRepo", + "ecto.migrate -r TestSyncRepo --migrations-path priv/test_repo/migrations", + "test" + ] + ] end end diff --git a/mix.lock b/mix.lock index 5f1a985..1cc60c4 100644 --- a/mix.lock +++ b/mix.lock @@ -1,34 +1,35 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "cachex": {:hex, :cachex, "4.0.4", "192b5a34ae7f2c866cf835d796005c31ccf65e50ee973fbbbda6c773c0f40322", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "a0417593fcca4b6bd0330bb3bbd507c379d5287213ab990dbc0dd704cedede0a"}, - "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, - "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, - "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, - "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, - "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, + "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, + "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"}, + "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, + "dialyxir": {:hex, :dialyxir, "1.4.8", "7ef671a8aff9948b091d8c30f09467fbb16e77305cda451bce48109a0f5e021c", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "cbd5a851571e5dfeb32aaf2e840bfa98b7864cb3071bf2ef5d95d1276b12e072"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, + "ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"}, "ecto_graph": {:hex, :ecto_graph, "0.2.0", "fdc840fe279a798480e3077ebef453a2503794bc4743db0cb1029ce54d6fb9a7", [:mix], [{:ecto, "> 1.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16.0", [hex: :libgraph, repo: "hexpm", optional: false]}], "hexpm", "89bf93374a1a631c2ed5d1a86fc7fef319501b657e22e8bc16bdea31f2f325c7"}, - "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, - "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"}, + "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, "ex_hash_ring": {:hex, :ex_hash_ring, "6.0.4", "bef9d2d796afbbe25ab5b5a7ed746e06b99c76604f558113c273466d52fa6d6b", [:mix], [], "hexpm", "89adabf31f7d3dfaa36802ce598ce918e9b5b33bae8909ac1a4d052e1e567d18"}, "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, - "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, - "mix_test_watch": {:hex, :mix_test_watch, "1.3.0", "2ffc9f72b0d1f4ecf0ce97b044e0e3c607c3b4dc21d6228365e8bc7c2856dc77", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f9e5edca976857ffac78632e635750d158df14ee2d6185a15013844af7570ffe"}, - "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, + "mix_test_watch": {:hex, :mix_test_watch, "1.4.0", "d88bcc4fbe3198871266e9d2f00cd8ae350938efbb11d3fa1da091586345adbb", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2b4693e17c8ead2ef56d4f48a0329891e8c2d0d73752c0f09272a2b17dc38d1b"}, + "mox": {:hex, :mox, "1.3.1", "ccd9ddeacc1eb1e4fe9ac42f99fcb49b214e3529b8c3b1bd7a60bc803a46f536", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "6aa44b17e40abed6c6d501e6393d229f2820fb69c5971fd17fe0d7f7eefa41fd"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, + "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, - "postgrex": {:hex, :postgrex, "0.21.0", "f44797ac23604af2640e84b98aa535b454b61fbd7da0f4034adba1787701913e", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "c35cd8f18b5c59da08eff27cb40c8533c561320af830821c740efd7e97c8ac9f"}, - "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, - "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, + "postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"}, + "postgrex_pgoutput": {:hex, :postgrex_pgoutput, "0.2.0", "2c51a0b8d03d218f561bbaa8d73b5f97b68e75b23eb764038188678d409339ef", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "e9d5d7c878d10fd815938561ed484182788ef131d6d34e0e2e29abbd35ba1f72"}, + "sleeplocks": {:hex, :sleeplocks, "1.1.4", "be657d2326fad313a9cbc45374dcd861416f2f2097bc71c4de1538b65c7ccc8e", [:rebar3], [], "hexpm", "bc12752ab0693ea4e4a3bcf4e063cef408d71197a3c0fad75497fabd475f5481"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, } diff --git a/priv/test_repo/migrations/20250225082733_person.exs b/priv/test_repo/migrations/20250225082733_person.exs index 12416f6..3fd2f8c 100644 --- a/priv/test_repo/migrations/20250225082733_person.exs +++ b/priv/test_repo/migrations/20250225082733_person.exs @@ -2,7 +2,8 @@ defmodule TestRepo.Migrations.Person do use Ecto.Migration def change do - create table("persons") do + create table("persons", primary_key: false) do + add :id, :binary_id, primary_key: true add :name, :string add :other, :integer, [:increment, start_value: 0] end diff --git a/priv/test_repo/migrations/20250225082743_post.exs b/priv/test_repo/migrations/20250225082743_post.exs index 860e50f..49dc1fe 100644 --- a/priv/test_repo/migrations/20250225082743_post.exs +++ b/priv/test_repo/migrations/20250225082743_post.exs @@ -2,12 +2,13 @@ defmodule TestRepo.Migrations.Post do use Ecto.Migration def change do - create table("posts") do + create table("posts", primary_key: false) do + add :id, :binary_id, primary_key: true add :name, :string add :body, :string - add :person_id, references(:persons, on_delete: :nilify_all) - add :comment_id, references(:posts) - add :other, :integer, [:increment, start_value: 0] + add :person_id, references(:persons, on_delete: :nilify_all, type: :binary_id) + add :comment_id, references(:posts, type: :binary_id) + add :other, references(:persons, on_delete: :nilify_all, type: :binary_id) end end end diff --git a/priv/test_repo/migrations/20250225082746_tag.exs b/priv/test_repo/migrations/20250225082746_tag.exs index 77032e5..467a65e 100644 --- a/priv/test_repo/migrations/20250225082746_tag.exs +++ b/priv/test_repo/migrations/20250225082746_tag.exs @@ -2,7 +2,8 @@ defmodule TestRepo.Migrations.Tag do use Ecto.Migration def change do - create table("tags") do + create table("tags", primary_key: false) do + add :id, :binary_id, primary_key: true add :name, :string end end diff --git a/priv/test_repo/migrations/20250225082750_label.exs b/priv/test_repo/migrations/20250225082750_label.exs index 18e04dd..c7bcca9 100644 --- a/priv/test_repo/migrations/20250225082750_label.exs +++ b/priv/test_repo/migrations/20250225082750_label.exs @@ -2,7 +2,8 @@ defmodule TestRepo.Migrations.Label do use Ecto.Migration def change do - create table("labels") do + create table("labels", primary_key: false) do + add :id, :binary_id, primary_key: true add :name, :string end end diff --git a/priv/test_repo/migrations/20250225082758_posts_tags.exs b/priv/test_repo/migrations/20250225082758_posts_tags.exs index 1f09bf8..5d38b19 100644 --- a/priv/test_repo/migrations/20250225082758_posts_tags.exs +++ b/priv/test_repo/migrations/20250225082758_posts_tags.exs @@ -2,9 +2,10 @@ defmodule TestRepo.Migrations.PostsTags do use Ecto.Migration def change do - create table(:posts_tags) do - add :tag_id, references(:tags, on_delete: :delete_all) - add :post_id, references(:posts, on_delete: :delete_all) + create table(:posts_tags, primary_key: false) do + add :id, :binary_id, primary_key: true + add :tag_id, references(:tags, on_delete: :delete_all, type: :binary_id), null: false + add :post_id, references(:posts, on_delete: :delete_all, type: :binary_id), null: false end create unique_index(:posts_tags, [:tag_id, :post_id]) diff --git a/priv/test_repo/migrations/20250225082934_posts_labels.exs b/priv/test_repo/migrations/20250225082934_posts_labels.exs index fd4aa60..7ac0c05 100644 --- a/priv/test_repo/migrations/20250225082934_posts_labels.exs +++ b/priv/test_repo/migrations/20250225082934_posts_labels.exs @@ -2,9 +2,10 @@ defmodule TestRepo.Migrations.PostsLabels do use Ecto.Migration def change do - create table(:posts_labels) do - add :label_id, references(:labels, on_delete: :delete_all) - add :post_id, references(:posts, on_delete: :delete_all) + create table(:posts_labels, primary_key: false) do + add :id, :binary_id, primary_key: true + add :label_id, references(:labels, on_delete: :delete_all, type: :binary_id), null: false + add :post_id, references(:posts, on_delete: :delete_all, type: :binary_id), null: false end create unique_index(:posts_labels, [:label_id, :post_id]) diff --git a/priv/test_repo/migrations/20250723124159_favourite_tags.exs b/priv/test_repo/migrations/20250723124159_favourite_tags.exs index 2f7e9a4..9ed028d 100644 --- a/priv/test_repo/migrations/20250723124159_favourite_tags.exs +++ b/priv/test_repo/migrations/20250723124159_favourite_tags.exs @@ -2,9 +2,10 @@ defmodule TestRepo.Migrations.FavouriteTags do use Ecto.Migration def change do - create table(:favourite_tags) do - add :tag_id, references(:tags, on_delete: :delete_all) - add :person_id, references(:persons, on_delete: :delete_all) + create table(:favourite_tags, primary_key: false) do + add :id, :binary_id, primary_key: true + add :tag_id, references(:tags, on_delete: :delete_all, type: :binary_id), null: false + add :person_id, references(:persons, on_delete: :delete_all, type: :binary_id), null: false end create unique_index(:favourite_tags, [:tag_id, :person_id]) diff --git a/priv/test_repo/migrations/20250727113631_favourite_posts.exs b/priv/test_repo/migrations/20250727113631_favourite_posts.exs index 4755400..3a26597 100644 --- a/priv/test_repo/migrations/20250727113631_favourite_posts.exs +++ b/priv/test_repo/migrations/20250727113631_favourite_posts.exs @@ -3,9 +3,10 @@ defmodule TestRepo.Migrations.FavouritePosts do def change do - create table(:favourite_posts) do - add :post_id, references(:posts, on_delete: :delete_all) - add :person_id, references(:persons, on_delete: :delete_all) + create table(:favourite_posts, primary_key: false) do + add :id, :binary_id, primary_key: true + add :post_id, references(:posts, on_delete: :delete_all, type: :binary_id), null: false + add :person_id, references(:persons, on_delete: :delete_all, type: :binary_id), null: false end create unique_index(:favourite_posts, [:post_id, :person_id]) diff --git a/priv/test_repo/migrations/20250727121119_favourite_people.exs b/priv/test_repo/migrations/20250727121119_favourite_people.exs index 53d1c9c..093435e 100644 --- a/priv/test_repo/migrations/20250727121119_favourite_people.exs +++ b/priv/test_repo/migrations/20250727121119_favourite_people.exs @@ -2,9 +2,10 @@ defmodule TestRepo.Migrations.FavouritePeople do use Ecto.Migration def change do - create table(:favourite_people) do - add :parent_id, references(:persons, on_delete: :delete_all) - add :child_id, references(:persons, on_delete: :delete_all) + create table(:favourite_people, primary_key: false) do + add :id, :binary_id, primary_key: true + add :parent_id, references(:persons, on_delete: :delete_all, type: :binary_id), null: false + add :child_id, references(:persons, on_delete: :delete_all, type: :binary_id), null: false end create unique_index(:favourite_people, [:parent_id, :child_id]) diff --git a/priv/test_repo/migrations/20260126154705_add_publication.exs b/priv/test_repo/migrations/20260126154705_add_publication.exs new file mode 100644 index 0000000..651e6f4 --- /dev/null +++ b/priv/test_repo/migrations/20260126154705_add_publication.exs @@ -0,0 +1,7 @@ +defmodule TestRepo.Migrations.AddPublication do + use Ecto.Migration + + def change do + execute "create publication ecto_sync for all tables" + end +end diff --git a/test/ecto_sync_test.exs b/test/ecto_sync_test.exs index ca6b326..08090fd 100644 --- a/test/ecto_sync_test.exs +++ b/test/ecto_sync_test.exs @@ -4,40 +4,19 @@ defmodule EctoSyncTest do import EctoSync.Helpers require Ecto.Query - @association_columns [:post_id, :label_id] - @posts_labels_events [ - {%{ - table_name: "posts_labels", - primary_key: :id, - columns: @association_columns, - association_columns: @association_columns - }, :deleted, label: :posts_labels_deleted, extra_columns: @association_columns}, - {%{ - table_name: "posts_labels", - primary_key: :id, - columns: @association_columns, - association_columns: @association_columns - }, :inserted, label: :posts_labels_inserted, extra_columns: @association_columns}, - {%{ - table_name: "posts_labels", - primary_key: :id, - columns: @association_columns, - association_columns: @association_columns - }, :updated, label: :posts_labels_updated, extra_columns: @association_columns} - ] - setup [:do_setup] describe "watchers/3" do test "all events are generated" do - watchers = - [ - {Post, :inserted, [extra_columns: []]}, - {Post, :updated, [extra_columns: []]}, - {Post, :deleted, [extra_columns: []]} - ] - |> watchers_with_labels() - |> MapSet.new() == EctoSync.watchers(Post) |> MapSet.new() + watchers = EctoSync.watchers(Post) |> MapSet.new() + + [ + {Post, :inserted, [extra_columns: []]}, + {Post, :updated, [extra_columns: []]}, + {Post, :deleted, [extra_columns: []]} + ] + |> watchers_with_labels() + |> MapSet.new() == watchers end test "adding a label to schema" do @@ -66,10 +45,12 @@ defmodule EctoSyncTest do {PostsTags, :deleted, [extra_columns: [:tag_id, :post_id]]}, {PostsTags, :inserted, [extra_columns: [:tag_id, :post_id]]}, {PostsTags, :updated, [extra_columns: [:tag_id, :post_id]]}, + {PostsLabels, :deleted, [extra_columns: [:label_id, :post_id]]}, + {PostsLabels, :inserted, [extra_columns: [:label_id, :post_id]]}, + {PostsLabels, :updated, [extra_columns: [:label_id, :post_id]]}, {Tag, :deleted, [extra_columns: []]}, {Tag, :inserted, [extra_columns: []]}, {Tag, :updated, [extra_columns: []]} - | @posts_labels_events ] |> watchers_with_labels() |> MapSet.new() @@ -80,7 +61,7 @@ defmodule EctoSyncTest do end test ":assocs option merges with other columns" do - watchers = + expected_watchers = [ {Post, :inserted, [extra_columns: [:id, :person_id]]}, {Post, :updated, [extra_columns: [:id, :person_id]]}, @@ -92,8 +73,11 @@ defmodule EctoSyncTest do |> watchers_with_labels() |> MapSet.new() - assert watchers == - EctoSync.watchers(Post, assocs: [:person], extra_columns: [:id]) |> MapSet.new() + watchers = + EctoSync.watchers(Post, assocs: [:person], extra_columns: [:id]) + |> MapSet.new() + + assert watchers == expected_watchers end test "raises with invalid inputs" do @@ -104,40 +88,44 @@ defmodule EctoSyncTest do describe "subscribe/3" do test "subscribe to non existing assocs", %{person: person} do assert [ - {{Person, :deleted}, person.id}, - {{Person, :updated}, person.id}, + {{Person, :deleted}, {:id, person.id}}, + {{Person, :updated}, {:id, person.id}}, {{Post, :inserted}, {:person_id, person.id}} - ] == - subscribe(person, assocs: [posts: [:tags]]) + ] + |> MapSet.new() == + subscribe(person, assocs: [posts: [:tags]]) |> MapSet.new() end test "subscribe to Ecto.Schema struct", %{person_with_posts: %{posts: [post, post2]} = person} do assert [ - {{Person, :deleted}, person.id}, - {{Person, :updated}, person.id}, - {{Post, :deleted}, post.id}, - {{Post, :deleted}, post2.id}, + {{Person, :deleted}, {:id, person.id}}, + {{Person, :updated}, {:id, person.id}}, + {{Post, :deleted}, {:id, post.id}}, + {{Post, :deleted}, {:id, post2.id}}, {{Post, :inserted}, {:person_id, person.id}}, - {{Post, :updated}, post.id}, - {{Post, :updated}, post2.id} - ] == - subscribe(person, assocs: [:posts]) + {{Post, :updated}, {:id, post.id}}, + {{Post, :updated}, {:id, post2.id}} + ] + |> MapSet.new() == + subscribe(person, assocs: [:posts]) |> MapSet.new() end test "subscribe to Ecto.Schema struct with inserted opt", %{ person_with_posts: %{posts: [post, post2]} = person } do assert [ - {{Person, :deleted}, person.id}, + {{Person, :deleted}, {:id, person.id}}, {{Person, :inserted}, nil}, - {{Person, :updated}, person.id}, - {{Post, :deleted}, post.id}, - {{Post, :deleted}, post2.id}, + {{Person, :updated}, {:id, person.id}}, + {{Post, :deleted}, {:id, post.id}}, + {{Post, :deleted}, {:id, post2.id}}, {{Post, :inserted}, {:person_id, person.id}}, - {{Post, :updated}, post.id}, - {{Post, :updated}, post2.id} - ] == + {{Post, :updated}, {:id, post.id}}, + {{Post, :updated}, {:id, post2.id}} + ] + |> MapSet.new() == subscribe(person, assocs: [:posts], inserted: true) + |> MapSet.new() end test "subscribe to a list of Ecto.Schema structs", %{ @@ -145,37 +133,39 @@ defmodule EctoSyncTest do person_with_posts: %{posts: [post, post2]} = person2 } do assert [ - {{Person, :deleted}, person.id}, - {{Person, :updated}, person.id}, - {{Person, :deleted}, person2.id}, - {{Person, :updated}, person2.id}, - {{Post, :deleted}, post.id}, - {{Post, :updated}, post.id}, - {{Post, :deleted}, post2.id}, - {{Post, :updated}, post2.id}, + {{Person, :deleted}, {:id, person.id}}, + {{Person, :updated}, {:id, person.id}}, + {{Person, :deleted}, {:id, person2.id}}, + {{Person, :updated}, {:id, person2.id}}, + {{Post, :deleted}, {:id, post.id}}, + {{Post, :updated}, {:id, post.id}}, + {{Post, :deleted}, {:id, post2.id}}, + {{Post, :updated}, {:id, post2.id}}, {{Post, :inserted}, {:person_id, person.id}}, {{Post, :inserted}, {:person_id, person2.id}} - ] == + ] + |> MapSet.new() == subscribe([person, person2], assocs: [:posts]) - |> Enum.sort_by(&elem(&1, 1)) + |> MapSet.new() end test "subscribe to assocs that are not preloaded", %{ person_with_posts: %{posts: [post, post2]} = person } do assert [ - {{Person, :deleted}, person.id}, - {{Person, :updated}, person.id}, - {{Post, :deleted}, post.id}, - {{Post, :updated}, post.id}, - {{Post, :deleted}, post2.id}, - {{Post, :updated}, post2.id}, + {{Person, :deleted}, {:id, person.id}}, + {{Person, :updated}, {:id, person.id}}, + {{Post, :deleted}, {:id, post.id}}, + {{Post, :updated}, {:id, post.id}}, + {{Post, :deleted}, {:id, post2.id}}, + {{Post, :updated}, {:id, post2.id}}, {{Post, :inserted}, {:person_id, person.id}}, {{PostsTags, :inserted}, {:post_id, post.id}}, {{PostsTags, :inserted}, {:post_id, post2.id}} - ] == + ] + |> MapSet.new() == subscribe([person], assocs: [posts: :tags]) - |> Enum.sort_by(&elem(&1, 1)) + |> MapSet.new() end test "no double subscribes", %{person: person} do @@ -183,11 +173,11 @@ defmodule EctoSyncTest do subscribe(person) end - assert [{self(), []}] == subscriptions({Person, :updated}, person.id) + assert [{self(), []}] == subscriptions({Person, :updated}, {:id, person.id}) end test "subscribe to label" do - assert [{:label, []}] == subscribe(:label) + assert [{:label, nil}] == subscribe(:label) end end @@ -200,12 +190,12 @@ defmodule EctoSyncTest do TestRepo.insert(%Post{person_id: person.id, tags: [%{name: "test"}]}) receive do - {EctoSync, {Post, :inserted, _} = sync_args} -> + {:ecto_sync, {Post, :inserted, _} = sync_args} -> person = do_preload(person, @preloads) synced = EctoSync.sync(person, sync_args) - assert synced == person + assert sorted(synced) == sorted(person) after - 500 -> + 1500 -> raise "no inserts" end @@ -214,10 +204,10 @@ defmodule EctoSyncTest do |> TestRepo.update() receive do - {EctoSync, {Tag, :updated, _} = sync_args} -> + {:ecto_sync, {Tag, :updated, _} = sync_args} -> person = do_preload(person, @preloads) synced = EctoSync.sync(person, sync_args) - assert synced == person + assert sorted(synced) == sorted(person) after 500 -> raise "no updates for tag" @@ -229,12 +219,12 @@ defmodule EctoSyncTest do test "inserted, arg empty list", %{person: person} do subscribe(person, assocs: @preloads) - {:ok, %{tags: [tag]} = post} = + {:ok, %{tags: [_tag]} = post} = TestRepo.insert(%Post{person_id: person.id, tags: [%{name: "test"}]}) |> do_preload(@preloads[:posts]) receive do - {EctoSync, {Post, :inserted, _} = sync_args} -> + {:ecto_sync, {Post, :inserted, _} = sync_args} -> assert [^post] = EctoSync.sync([], sync_args) after 500 -> @@ -245,7 +235,7 @@ defmodule EctoSyncTest do describe "integrations" do test "types of sync arguments for insert", %{person: person} do - assert [{{Post, :inserted}, nil}] == subscribe(Post, :inserted) + assert [{{Post, :inserted}, nil}] == subscribe({Post, :inserted}) person = do_preload(person, [:posts]) {:ok, post} = @@ -255,7 +245,7 @@ defmodule EctoSyncTest do sync_opts = [preloads: %{Post => [person: [:posts]]}] receive do - {EctoSync, {Post, :inserted, _} = sync_args} -> + {:ecto_sync, {Post, :inserted, _} = sync_args} -> assert Post == EctoSync.get(sync_args, sync_opts).__struct__ assert do_preload(person, posts: [person: :posts]) == @@ -280,7 +270,7 @@ defmodule EctoSyncTest do end test "only one message is sent after insert", %{person: person} do - assert [{{Post, :inserted}, nil}] == subscribe(Post, :inserted) + assert [{{Post, :inserted}, nil}] == subscribe({Post, :inserted}) {:ok, _post} = TestRepo.insert(%Post{person_id: person.id}) @@ -295,10 +285,10 @@ defmodule EctoSyncTest do {:ok, post} = TestRepo.insert(%Post{person_id: person.id}) - # assert [{{Post, :updated}, post_id} ] == subscribe({Post, :updated}, post_id) + # assert [{{Post, :{:id, updated}}, post_id} ] == subscribe({Post, :updated}, post_id) receive do - {EctoSync, {Post, :inserted, _} = sync_args} -> + {:ecto_sync, {Post, :inserted, _} = sync_args} -> assert do_preload(person, preloads) == EctoSync.sync(person, sync_args) end @@ -307,7 +297,7 @@ defmodule EctoSyncTest do |> TestRepo.update() receive do - {EctoSync, {Post, :updated, _} = sync_args} -> + {:ecto_sync, {Post, :updated, _} = sync_args} -> assert do_preload(person, preloads) == EctoSync.sync(person, sync_args, preloads: %{Post => [:person]}) after @@ -321,8 +311,8 @@ defmodule EctoSyncTest do TestRepo.insert(%Post{person_id: person.id}) assert [ - {{Post, :deleted}, post.id}, - {{Post, :updated}, post.id} + {{Post, :deleted}, {:id, post.id}}, + {{Post, :updated}, {:id, post.id}} ] == subscribe(post) @@ -338,12 +328,12 @@ defmodule EctoSyncTest do expected_posts = expected.posts receive do - {EctoSync, {Post, :deleted, _} = sync_args} -> + {:ecto_sync, {Post, :deleted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args) - assert synced == expected + assert sorted(synced) == sorted(expected) synced = EctoSync.sync(posts, sync_args) - assert expected_posts == synced + assert sorted(expected_posts) == sorted(synced) after 500 -> raise "no deletes" @@ -365,7 +355,7 @@ defmodule EctoSyncTest do sort = fn enum -> Enum.sort_by(enum, & &1.id) end receive do - {EctoSync, {Post, :updated, _} = sync_args} -> + {:ecto_sync, {Post, :updated, _} = sync_args} -> synced = EctoSync.sync([person, person], sync_args) assert is_list(synced) @@ -394,9 +384,9 @@ defmodule EctoSyncTest do TestRepo.get(Person, person1.id) |> do_preload(preloads) receive do - {EctoSync, {Post, :updated, _} = sync_args} -> + {:ecto_sync, {Post, :updated, _} = sync_args} -> synced = EctoSync.sync(person1, sync_args) - assert person1_expected_after_update == synced + assert sorted(person1_expected_after_update) == sorted(synced) after 500 -> raise "no updates for update1" end @@ -409,9 +399,9 @@ defmodule EctoSyncTest do TestRepo.get(Person, person1.id) |> do_preload(preloads) receive do - {EctoSync, {Post, :updated, _} = sync_args} -> + {:ecto_sync, {Post, :updated, _} = sync_args} -> synced = EctoSync.sync(person1_expected_after_update, sync_args) - assert person1_expected_after_update_2 == synced + assert sorted(person1_expected_after_update_2) == sorted(synced) after 500 -> raise "no updates for update2" end @@ -433,9 +423,9 @@ defmodule EctoSyncTest do expected = TestRepo.get(Post, post.id) |> do_preload(@preloads) receive do - {EctoSync, {Post, :updated, _} = sync_args} -> + {:ecto_sync, {Post, :updated, _} = sync_args} -> synced = EctoSync.sync(post, sync_args) - assert expected == synced + assert sorted(expected) == sorted(synced) after 500 -> raise "no update" end @@ -454,14 +444,14 @@ defmodule EctoSyncTest do end receive do - {EctoSync, {Person, :deleted, _} = sync_args} -> + {:ecto_sync, {Person, :deleted, _} = sync_args} -> synced = EctoSync.sync(post1, sync_args) - assert do_preload(post1, @preloads) == synced + assert do_preload(post1, @preloads) |> sorted == sorted(synced) after 500 -> raise "no post update" end - refute_received({EctoSync, {Person, :updated, _}}) + refute_received({:ecto_sync, {Person, :updated, _}}) end test "update", %{person_with_posts_and_tags: %{posts: [post1 | _]} = person} do @@ -474,14 +464,14 @@ defmodule EctoSyncTest do |> TestRepo.update() receive do - {EctoSync, {Person, :updated, _} = sync_args} -> + {:ecto_sync, {Person, :updated, _} = sync_args} -> synced = EctoSync.sync(post1, sync_args) - assert do_preload(post1, @preloads) == synced + assert do_preload(post1, @preloads) |> sorted == sorted(synced) after 500 -> raise "no person update" end - refute_received({EctoSync, {Person, :updated, _}}) + refute_received({:ecto_sync, {Person, :updated, _}}) end test "update assoc is changed", %{ @@ -502,25 +492,25 @@ defmodule EctoSyncTest do |> do_preload(@preloads) receive do - {EctoSync, {Post, :updated, _} = sync_args} -> + {:ecto_sync, {Post, :updated, _} = sync_args} -> synced = EctoSync.sync(post1, sync_args) - assert preloaded == synced + assert sorted(preloaded) == sorted(synced) after 500 -> raise "no post update" end - refute_received({EctoSync, {Person, :updated, _}}) + refute_received({:ecto_sync, {Person, :updated, _}}) end test "preloads", %{person: person} do - assert [{{Post, :inserted}, nil}] == subscribe(Post, :inserted) + assert [{{Post, :inserted}, nil}] == subscribe({Post, :inserted}) {:ok, post} = TestRepo.insert(%Post{person_id: person.id}) receive do - {EctoSync, {Post, :inserted, _} = sync_args} -> + {:ecto_sync, {Post, :inserted, _} = sync_args} -> synced = EctoSync.sync(post, sync_args, preloads: %{Post => :person}) - assert synced == post |> do_preload([:person]) + assert sorted(synced) == post |> do_preload([:person]) |> sorted assert [^post] = EctoSync.sync([], sync_args) assert ^post = EctoSync.sync(nil, sync_args) after @@ -540,13 +530,18 @@ defmodule EctoSyncTest do subscribe(post, assocs: preloads) {:ok, _person} = TestRepo.insert(%Person{}) - {:ok, _person} = TestRepo.insert(%Person{other_posts: [post]}) - expected = TestRepo.get(Post, post.id) |> do_preload(preloads) + + {:ok, _person} = + TestRepo.insert(%Person{other_posts: [post]}) + + expected = + TestRepo.get(Post, post.id) + |> do_preload(preloads) receive do - {EctoSync, {Post, :updated, _} = sync_args} -> + {:ecto_sync, {Post, :updated, _} = sync_args} -> synced = EctoSync.sync(post, sync_args) - assert expected == synced + assert sorted(expected) == sorted(synced) after 500 -> raise "no update" end @@ -563,9 +558,9 @@ defmodule EctoSyncTest do {:ok, _post} = TestRepo.insert(%Post{person_id: person.id}) receive do - {EctoSync, {Post, :inserted, _} = sync_args} -> + {:ecto_sync, {Post, :inserted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted == sorted(synced) synced after 500 -> raise "nothing POSTS" @@ -582,10 +577,10 @@ defmodule EctoSyncTest do |> TestRepo.update() receive do - {EctoSync, {Post, :updated, _} = sync_args} -> + {:ecto_sync, {Post, :updated, _} = sync_args} -> %{posts: synced_posts} = EctoSync.sync(person, sync_args) %{posts: preloaded_posts} = do_preload(person, @preloads) - assert preloaded_posts |> Enum.sort() == synced_posts |> Enum.sort() + assert preloaded_posts |> sorted() == synced_posts |> sorted() after 500 -> raise "no post update" end @@ -599,10 +594,10 @@ defmodule EctoSyncTest do {:ok, _} = TestRepo.delete(post1) receive do - {EctoSync, {Post, :deleted, _} = sync_args} -> + {:ecto_sync, {Post, :deleted, _} = sync_args} -> %{posts: synced_posts} = EctoSync.sync(person, sync_args) %{posts: preloaded_posts} = do_preload(person, @preloads) - assert preloaded_posts |> Enum.sort() == synced_posts |> Enum.sort() + assert preloaded_posts |> sorted() == synced_posts |> sorted() after 500 -> raise "no post update" end @@ -631,51 +626,52 @@ defmodule EctoSyncTest do Task.async(fn -> subscribe(person2, assocs: [:posts]) + self() + receive do - {EctoSync, {Post, :inserted, _} = sync_args} -> + {:ecto_sync, {Post, :inserted, _} = sync_args} -> synced = EctoSync.sync(person2, sync_args) - assert person2_expected_after_update == synced + assert sorted(person2_expected_after_update) == sorted(synced) after - 3000 -> raise "no inserts in other process" + 5000 -> raise "no inserts in other process" end end) receive do - {EctoSync, {Post, :updated, _} = sync_args} -> + {:ecto_sync, {Post, :updated, _} = sync_args} -> synced = EctoSync.sync(person1, sync_args) - assert person1_expected_after_update == synced + assert sorted(person1_expected_after_update) == sorted(synced) after - 500 -> raise "no updates for person1" + 7000 -> raise "no updates for person1" end - refute_received({EctoSync, {Post, :inserted, _}}) - assert Task.await(other_process) + refute_received({:ecto_sync, {Post, :inserted, _}}) + assert Task.await(other_process, 10000) - # other_process = - # Task.async(fn -> - # subscribe(person2_expected_after_update, assocs: [:posts]) + other_process = + Task.async(fn -> + subscribe(person2_expected_after_update, assocs: [:posts]) - # person2_expected_after_update = TestRepo.get(Person, person2.id) |> do_preload(preloads) + person2_expected_after_update = TestRepo.get(Person, person2.id) |> do_preload(preloads) - # receive do - # {EctoSync, {Post, :updated, _} = sync_args} -> - # synced = EctoSync.sync(person2, sync_args) + receive do + {:ecto_sync, {Post, :updated, _} = sync_args} -> + synced = EctoSync.sync(person2, sync_args) - # assert person2_expected_after_update == synced - # after - # 3000 -> raise "no updates in other process" - # end - # end) + assert sorted(person2_expected_after_update) == sorted(synced) + after + 3000 -> raise "no updates in other process" + end + end) - # {:ok, _} = - # Ecto.Changeset.change(post1, %{name: "updated again"}) - # |> TestRepo.update() - # |> IO.inspect() + {:ok, _} = + Ecto.Changeset.change(post1, %{name: "updated again"}) + |> TestRepo.update() - # assert Task.await(other_process) + assert Task.await(other_process, 10000) - refute_received({EctoSync, {Post, :updated, _}}) + refute_received({:ecto_sync, {Post, :updated, _}}) end test "preloads", %{person_with_posts_and_tags: person} do @@ -687,11 +683,11 @@ defmodule EctoSyncTest do person = receive do - {EctoSync, {Post, :inserted, _} = sync_args} -> + {:ecto_sync, {Post, :inserted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args, preloads: %{Post => [:tags, :labels]}) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == sorted(synced) synced after 500 -> raise "nothing POSTS" @@ -700,11 +696,11 @@ defmodule EctoSyncTest do {:ok, _post} = TestRepo.insert(%Post{person_id: person.id}) receive do - {EctoSync, {Post, :inserted, _} = sync_args} -> + {:ecto_sync, {Post, :inserted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args, preloads: %{Post => [:tags, :labels]}) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == sorted(synced) synced after 500 -> raise "nothing POSTS" @@ -727,11 +723,11 @@ defmodule EctoSyncTest do {:ok, _post} = TestRepo.insert(%Post{person_id: person.id, name: "test"}) receive do - {EctoSync, {Post, :inserted, _} = sync_args} -> + {:ecto_sync, {Post, :inserted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args, preloads: %{Post => [:tags, :labels]}) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() after 500 -> raise "nothing POSTS" end @@ -748,12 +744,12 @@ defmodule EctoSyncTest do |> TestRepo.update() receive do - {EctoSync, {Post, :updated, _} = sync_args} -> + {:ecto_sync, {Post, :updated, _} = sync_args} -> synced = EctoSync.sync(person, sync_args, preloads: %{Post => [:tags, :labels]}) - assert do_preload(person, @preloads).test_posts |> Enum.sort() == - synced.test_posts |> Enum.sort() + assert do_preload(person, @preloads).test_posts |> sorted() == + synced.test_posts |> sorted() after 500 -> raise "nothing POSTS" end @@ -764,12 +760,12 @@ defmodule EctoSyncTest do |> TestRepo.update() receive do - {EctoSync, {Post, :updated, _} = sync_args} -> + {:ecto_sync, {Post, :updated, _} = sync_args} -> synced = EctoSync.sync(person, sync_args, preloads: %{Post => [:tags, :labels]}) - assert do_preload(person, @preloads).test_posts |> Enum.sort() == - synced.test_posts |> Enum.sort() + assert do_preload(person, @preloads).test_posts |> sorted() == + synced.test_posts |> sorted() after 500 -> raise "nothing POSTS" end @@ -786,12 +782,12 @@ defmodule EctoSyncTest do |> TestRepo.update() receive do - {EctoSync, {Post, :updated, _} = sync_args} -> + {:ecto_sync, {Post, :updated, _} = sync_args} -> synced = EctoSync.sync(person, sync_args, preloads: %{Post => [:tags, :labels]}) - assert do_preload(person, @preloads).test_posts |> Enum.sort() == - synced.test_posts |> Enum.sort() + assert do_preload(person, @preloads).test_posts |> sorted() == + synced.test_posts |> sorted() after 500 -> raise "nothing POSTS" end @@ -801,11 +797,11 @@ defmodule EctoSyncTest do |> TestRepo.delete() receive do - {EctoSync, {Post, :deleted, _} = sync_args} -> + {:ecto_sync, {Post, :deleted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args, preloads: %{Post => [:tags, :labels]}) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() after 500 -> raise "nothing POSTS" end @@ -823,10 +819,10 @@ defmodule EctoSyncTest do TestRepo.insert(%Post{person_id: person.id, name: "test", tags: [%{name: "test tag"}]}) receive do - {EctoSync, {Post, :inserted, _} = sync_args} -> + {:ecto_sync, {Post, :inserted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args, preloads: %{Post => [:labels]}) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() after 500 -> raise "nothing POSTS" end @@ -840,11 +836,11 @@ defmodule EctoSyncTest do TestRepo.delete(tag) receive do - {EctoSync, {Tag, :deleted, _} = sync_args} -> + {:ecto_sync, {Tag, :deleted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args, preloads: %{Post => [:tags, :labels]}) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() after 500 -> raise "nothing POSTS" end @@ -864,9 +860,9 @@ defmodule EctoSyncTest do person = receive do - {EctoSync, {PostsTags, :inserted, _} = sync_args} -> + {:ecto_sync, {PostsTags, :inserted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() synced after 500 -> raise "nothing POSTS" @@ -880,11 +876,11 @@ defmodule EctoSyncTest do |> do_preload([:posts]) receive do - {EctoSync, {PostsTags, :inserted, _} = sync_args} -> + {:ecto_sync, {PostsTags, :inserted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() after 500 -> raise "nothing POSTS" end @@ -895,18 +891,18 @@ defmodule EctoSyncTest do subscribe(person, assocs: @preloads) - {:ok, _tag} = + {:ok, %{id: tag_id}} = TestRepo.insert(%Tag{name: "test", posts: [post1]}) |> do_preload([:posts]) person = receive do - {EctoSync, {PostsTags, :inserted, _} = sync_args} -> + {:ecto_sync, {PostsTags, :inserted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() synced after - 500 -> raise "nothing POSTS" + 500 -> raise "nothing Tag inserted" end {:ok, _tag} = @@ -917,17 +913,17 @@ defmodule EctoSyncTest do |> do_preload([:posts]) receive do - {EctoSync, {PostsTags, :inserted, _} = sync_args} -> + {:ecto_sync, {PostsTags, :inserted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() after 500 -> raise "nothing POSTS" end end - test "join_through is updated", %{person_with_posts_and_tags: person} do + test "join_through is deleted", %{person_with_posts_and_tags: person} do %{posts: [%{tags: [from_tag | _]} | _]} = person = do_preload(person, @preloads) subscribe(person, assocs: [posts: :tags]) @@ -939,12 +935,12 @@ defmodule EctoSyncTest do |> TestRepo.update() receive do - {EctoSync, {PostsTags, _, _} = sync_args} -> + {:ecto_sync, {PostsTags, :deleted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() after - 500 -> raise "nothing POSTS" + 5000 -> raise "nothing Tag" end end @@ -965,9 +961,9 @@ defmodule EctoSyncTest do |> do_preload([:posts]) receive do - {EctoSync, {Tag, :updated, _} = sync_args} -> + {:ecto_sync, {Tag, :updated, _} = sync_args} -> synced = EctoSync.sync(person, sync_args) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() after 500 -> raise "no tag update" end @@ -992,11 +988,11 @@ defmodule EctoSyncTest do |> Enum.each(fn {Tag, :updated, {^tag_id, _}} = sync_args -> synced = EctoSync.sync(person, sync_args) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() {Tag, :updated, _} = sync_args -> synced = EctoSync.sync(person, sync_args) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() {Tag, :inserted, _} -> false @@ -1013,9 +1009,9 @@ defmodule EctoSyncTest do TestRepo.delete(tag) receive do - {EctoSync, {Tag, :deleted, _} = sync_args} -> + {:ecto_sync, {Tag, :deleted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() after 500 -> raise "no tag delete" end @@ -1024,28 +1020,28 @@ defmodule EctoSyncTest do describe "many to many with join through table" do @preloads [posts: [:labels]] - test "inserted", %{person_with_posts_and_tags: person} do - %{posts: [_post1, post2]} = person = do_preload(person, @preloads) + test "inserted", %{person_with_posts_and_labels: person} do + %{posts: [post]} = person = do_preload(person, @preloads) {:ok, label} = TestRepo.insert(%Label{name: "new label"}) subscribe(person, assocs: @preloads) {:ok, _} = - Ecto.Changeset.change(post2, %{labels: [label | post2.labels]}) + Ecto.Changeset.change(post, %{labels: [label | post.labels]}) |> TestRepo.update() receive do - {EctoSync, sync_args} -> + {:ecto_sync, sync_args} -> synced = EctoSync.sync(person, sync_args) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() after 500 -> raise "nothing POSTS" end end - test "updated", %{person_with_posts_and_tags: person} do - %{posts: [_post1, %{labels: [label | _]}]} = person = do_preload(person, @preloads) + test "updated", %{person_with_posts_and_labels: person} do + %{posts: [%{labels: [label]}]} = person = do_preload(person, @preloads) subscribe(person, assocs: @preloads) @@ -1054,24 +1050,24 @@ defmodule EctoSyncTest do |> TestRepo.update() receive do - {EctoSync, sync_args} -> + {:ecto_sync, sync_args} -> synced = EctoSync.sync(person, sync_args) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() after 500 -> raise "nothing POSTS" end end - test "deleted", %{person_with_posts_and_tags: person} do - %{posts: [_, %{labels: [label | _]}]} = person = do_preload(person, @preloads) + test "deleted", %{person_with_posts_and_labels: person} do + %{posts: [%{labels: [label]}]} = person = do_preload(person, @preloads) subscribe(person, assocs: @preloads) TestRepo.delete(label) receive do - {EctoSync, {_, :deleted, _} = sync_args} -> + {:ecto_sync, {_, :deleted, _} = sync_args} -> synced = EctoSync.sync(person, sync_args) - assert do_preload(person, @preloads) == synced + assert do_preload(person, @preloads) |> sorted() == synced |> sorted() after 500 -> raise "no label delete" end @@ -1081,14 +1077,14 @@ defmodule EctoSyncTest do describe "subscriptions/0" do test "subscriptions can be listed", %{person: person} do subscribe(person) - assert [{self(), []}] == subscriptions({Person, :updated}, person.id) + assert [{self(), []}] == subscriptions({Person, :updated}, {:id, person.id}) end test "assocs are stored in subscriptions", %{person: person} do subscribe(person, assocs: [posts: [:tags]]) assert [{self(), [assocs: [posts: [:tags]]]}] == - subscriptions({Person, :updated}, person.id) + subscriptions({Person, :updated}, {:id, person.id}) assert [{self(), [assocs: [:tags]]}] == subscriptions({Post, :inserted}, {:person_id, person.id}) @@ -1096,9 +1092,9 @@ defmodule EctoSyncTest do test "subscriptions are up to date after unsubscribing", %{person: person} do subscribe(person) - assert [{self(), []}] == subscriptions({Person, :updated}, person.id) + assert [{self(), []}] == subscriptions({Person, :updated}, {:id, person.id}) unsubscribe(person) - assert [] == subscriptions({Person, :updated}, person.id) + assert [] == subscriptions({Person, :updated}, {:id, person.id}) end end @@ -1145,17 +1141,17 @@ defmodule EctoSyncTest do describe "Repo" do test "subscribe/2 full flow", %{person: person} do preloads = [posts: [:person]] - person = do_preload(person, preloads) + person = do_preload(person, preloads, TestRepo) subscribe(person, assocs: preloads) {:ok, post} = TestRepo.insert(%Post{person_id: person.id}) - # assert [{{Post, :updated}, post_id} ] == subscribe({Post, :updated}, post_id) + # assert [{{Post, :{:id, updated}}, post_id} ] == subscribe({Post, :updated}, post_id) receive do - {EctoSync, {Post, :inserted, _} = sync_args} -> - assert do_preload(person, preloads) == EctoSync.sync(person, sync_args) + {:ecto_sync, {Post, :inserted, _} = sync_args} -> + assert do_preload(person, preloads, TestRepo) == EctoSync.sync(person, sync_args) end {:ok, _updated} = @@ -1163,7 +1159,7 @@ defmodule EctoSyncTest do |> TestRepo.update() receive do - {EctoSync, {Post, :updated, _} = sync_args} -> + {:ecto_sync, {Post, :updated, _} = sync_args} -> assert do_preload(person, preloads) == EctoSync.sync(person, sync_args, preloads: %{Post => [:person]}) after @@ -1175,13 +1171,19 @@ defmodule EctoSyncTest do defp do_setup(_) do start_supervised!(TestRepo) - start_supervised!(TestSyncRepo) {:ok, person} = TestRepo.insert(%Person{}) {:ok, person_with_post_and_tags} = TestRepo.insert(%Person{ posts: [ %Post{tags: [%Tag{name: "tag"}, %Tag{name: "other_tag"}]}, + %Post{tags: [%Tag{name: "tag"}, %Tag{name: "other_tag"}]} + ] + }) + + {:ok, person_with_post_and_labels} = + TestRepo.insert(%Person{ + posts: [ %Post{labels: [%Label{name: "label"}]} ] }) @@ -1191,6 +1193,7 @@ defmodule EctoSyncTest do start_supervised!({ EctoSync, repo: TestRepo, + adapter: EctoSync.Adapters.Postgres.Wal, watchers: [{Label, :inserted, label: :label}] |> EctoSync.watchers(Post, @@ -1203,25 +1206,28 @@ defmodule EctoSyncTest do person: person, preloads: [:person], person_with_posts: person_with_posts, - person_with_posts_and_tags: person_with_post_and_tags + person_with_posts_and_tags: person_with_post_and_tags, + person_with_posts_and_labels: person_with_post_and_labels ] end - defp do_preload({:ok, value}, preloads) do - {:ok, do_preload(value, preloads)} + defp do_preload(value, preloads, repo \\ TestRepo) + + defp do_preload({:ok, value}, preloads, repo) do + {:ok, do_preload(value, preloads, repo)} end - defp do_preload(value, preloads) do + defp do_preload(value, preloads, repo) do assocs = value.__struct__.__schema__(:associations) fields = assocs ++ preloads Ecto.reset_fields(value, fields) - |> TestRepo.preload(preloads) + |> repo.preload(preloads) end defp flush(messages \\ []) do receive do - {EctoSync, message} -> flush([message | messages]) + {:ecto_sync, message} -> flush([message | messages]) after 500 -> messages @@ -1231,7 +1237,7 @@ defmodule EctoSyncTest do defp watchers_with_labels(watchers) do watchers - |> Enum.map(fn {schema, event, opts} = watcher -> + |> Enum.map(fn {schema, event, opts} -> label = case schema do %{table_name: table} -> @@ -1248,4 +1254,17 @@ defmodule EctoSyncTest do {schema, event, Keyword.put_new(opts, :label, label)} end) end + + defp sorted(list) when is_list(list) do + Enum.sort_by(list, & &1.id) + |> Enum.map(&sorted/1) + end + + defp sorted(struct) when is_struct(struct) do + struct + |> Map.keys() + |> Enum.reduce(struct, &Map.update!(&2, &1, fn v -> sorted(v) end)) + end + + defp sorted(rest), do: rest end diff --git a/test/ecto_sync_test/adapters_test.exs b/test/ecto_sync_test/adapters_test.exs new file mode 100644 index 0000000..4f226b0 --- /dev/null +++ b/test/ecto_sync_test/adapters_test.exs @@ -0,0 +1,73 @@ +defmodule EctoSyncTest.AdapterTests do + use EctoSync.RepoCase, async: false + + @watchers [{Label, :inserted, label: :label}] + |> EctoSync.watchers(Post, + assocs: [:tags, :labels, person: [:favourite_tags]], + extra_columns: [:person_id] + ) + + describe "postgres WAL adapter" do + setup [:do_setup, :wal] + + test "events are sent" do + EctoSync.subscribe({Post, :inserted}) + + EctoSync.subscriptions({Post, :inserted}) + + {:ok, %{id: id}} = TestRepo.insert(%Post{}) + + receive do + {:ecto_sync, {Post, :inserted, _} = sync_args} -> + assert id == EctoSync.get(sync_args).id + after + 1000 -> + raise "no message on inserted" + end + end + end + + describe "postgres NOTIFY adapter" do + setup [:do_setup, :notify] + + test "events are sent" do + EctoSync.subscribe({Post, :inserted}) + + EctoSync.subscriptions({Post, :inserted}) + + {:ok, %{id: id}} = TestRepo.insert(%Post{}) + + receive do + {:ecto_sync, {Post, :inserted, _} = sync_args} -> + assert id == EctoSync.get(sync_args).id + after + 1000 -> + raise "no message on inserted" + end + end + end + + defp do_setup(_) do + start_supervised!(TestRepo) + + :ok + end + + defp wal(_) do + start_link_supervised!({ + EctoSync, + repo: TestRepo, adapter: EctoSync.Adapters.Postgres.Wal, watchers: @watchers + }) + + :ok + end + + defp notify(_) do + start_link_supervised!({ + EctoSync, + repo: TestRepo, adapter: EctoSync.Adapters.Postgres.Notify, watchers: @watchers + }) + + :ok + end +end diff --git a/test/ecto_sync_test/pub_sub_adapter_test.exs b/test/ecto_sync_test/pub_sub_adapter_test.exs deleted file mode 100644 index 8110593..0000000 --- a/test/ecto_sync_test/pub_sub_adapter_test.exs +++ /dev/null @@ -1,32 +0,0 @@ -defmodule EctoSync.PubSubAdapterTest do - use ExUnit.Case, async: false - - describe "broadcast" do - setup do - start_supervised({Phoenix.PubSub, adapter: EctoSync.PubSub, name: :pub_sub}) - :ok - end - - test "ref is the same for each subscriber for a message" do - Phoenix.PubSub.subscribe(:pub_sub, "test") - Phoenix.PubSub.subscribe(:pub_sub, "test") - Phoenix.PubSub.subscribe(:pub_sub, "test") - - :persistent_term.put(EctoSync, %{repo: TestRepo, cache_name: :test}) - Phoenix.PubSub.broadcast(:pub_sub, "test", {{Test, :updated}, %{id: :world}}) - - refs = - for _ <- 1..3 do - receive do - {_, {_, ref}} -> ref - after - 1000 -> - :nothing - end - end - - refute Enum.uniq(refs) == [:nothing] - assert Enum.uniq(refs) |> Enum.count() == 1 - end - end -end diff --git a/test/ecto_sync_test/repo_test.exs b/test/ecto_sync_test/repo_test.exs new file mode 100644 index 0000000..c3ab9af --- /dev/null +++ b/test/ecto_sync_test/repo_test.exs @@ -0,0 +1,176 @@ +defmodule EctoSync.RepoTest do + use EctoSync.RepoCase, async: false + import EctoSync + import EctoSync.Helpers + require Ecto.Query + + setup [:do_setup] + + describe "EctoSync.Repo.__prepare_op__" do + test "raises will decrement the local counter", %{person: person} do + current_counter = get_local_id_counters() + + assert_raise Ecto.ChangeError, + "value `\"blablabla\"` for `Person.id` in `update` does not match type :binary_id", + fn -> + person + |> Ecto.Changeset.change(%{id: "blablabla"}) + |> TestSyncRepo.update() + end + + assert current_counter == get_local_id_counters() + end + + test "invalid changesets will decrement local counter", %{person: person} do + current_counter = get_local_id_counters() + + person + |> Ecto.Changeset.change(%{name: ""}) + |> Ecto.Changeset.validate_length(:name, min: 1) + |> TestSyncRepo.update() + + assert current_counter == get_local_id_counters() + end + + test "if primary key is set, it won't be overridden" do + id = Ecto.UUID.generate() + {:ok, person} = TestSyncRepo.insert(%Person{id: id}) + assert person.id == id + end + end + + describe "EctoSync.Repo" do + test "own updates are not received", %{person: person} do + preloads = [posts: [:person]] + person = do_preload(person, preloads, TestSyncRepo) + + subscribe(person, assocs: preloads) + + {:ok, post} = TestSyncRepo.insert(Ecto.Changeset.change(%Post{}, %{person_id: person.id})) + + receive do + {:ecto_sync, {Post, :inserted, _} = sync_args} -> + assert person == EctoSync.sync(person, sync_args) + + assert do_preload(person, preloads, TestSyncRepo) == + EctoSync.sync(person, sync_args, force: true) + end + + {:ok, _updated} = + Ecto.Changeset.change(post, %{name: "updated"}) + |> TestSyncRepo.update() + + receive do + {:ecto_sync, {Post, :updated, _} = sync_args} -> + assert person == EctoSync.sync(person, sync_args) + + assert do_preload(person, preloads) == + EctoSync.sync(person, sync_args, preloads: %{Post => [:person]}, force: true) + after + 500 -> + raise "no updates" + end + end + + test "inserts from other process is received", %{person: person} do + preloads = [posts: [:person]] + person = do_preload(person, preloads, TestSyncRepo) + + subscribe(person, assocs: preloads) + + Task.async(fn -> + {:ok, post} = TestSyncRepo.insert(Ecto.Changeset.change(%Post{}, %{person_id: person.id})) + end) + |> Task.await() + + receive do + {:ecto_sync, {Post, :inserted, _} = sync_args} -> + assert do_preload(person, preloads, TestSyncRepo) == + EctoSync.sync(person, sync_args, force: true) + end + end + + test "updates from other process is received", %{person: person} do + preloads = [posts: [:person]] + person = do_preload(person, preloads, TestSyncRepo) + + subscribe(person, assocs: preloads) + + Task.async(fn -> + {:ok, _} = TestSyncRepo.update(Ecto.Changeset.change(person, %{name: "updated"})) + end) + |> Task.await() + + receive do + {:ecto_sync, {Person, :updated, _} = sync_args} -> + assert TestSyncRepo.get(Person, person.id) |> do_preload(preloads, TestSyncRepo) == + EctoSync.sync(person, sync_args) + end + end + + test "assoc updates are not received from own process", %{ + person_with_posts: %{posts: [post | _] = _posts} = person + } do + preloads = [posts: [:person]] + + subscribe(person, assocs: preloads) + + IO.puts("pre-update") + + {:ok, _updated} = + Ecto.Changeset.change(post, %{name: "updated"}) + |> TestSyncRepo.update() + + IO.puts("updated") + + receive do + {:ecto_sync, {Post, :updated, _} = sync_args} -> + assert person == EctoSync.sync(person, sync_args) + after + 500 -> + raise "no updates" + end + end + end + + defp do_setup(_) do + start_supervised!(TestSyncRepo) + + start_supervised!({ + EctoSync, + repo: TestSyncRepo, + adapter: EctoSync.Adapters.Postgres.Wal, + watchers: + [{Label, :inserted, label: :label}] + |> EctoSync.watchers(Post, + assocs: [:tags, :labels, person: [:favourite_tags]], + extra_columns: [:person_id] + ) + }) + + {:ok, person} = TestSyncRepo.insert(Ecto.Changeset.cast(%Person{}, %{name: "setup"}, [:name])) + + {:ok, person_with_posts} = TestSyncRepo.insert(%Person{posts: [%Post{}, %Post{}]}) + + [ + person: person, + preloads: [:person], + person_with_posts: person_with_posts + # person_with_posts_and_tags: person_with_post_and_tags + ] + end + + defp do_preload(value, preloads, repo \\ TestSyncRepo) + + defp do_preload({:ok, value}, preloads, repo) do + {:ok, do_preload(value, preloads, repo)} + end + + defp do_preload(value, preloads, repo) do + assocs = value.__struct__.__schema__(:associations) + fields = assocs ++ preloads + + Ecto.reset_fields(value, fields) + |> repo.preload(preloads) + end +end diff --git a/test/ecto_sync_test/syncer_test.exs b/test/ecto_sync_test/syncer_test.exs new file mode 100644 index 0000000..8ccf4de --- /dev/null +++ b/test/ecto_sync_test/syncer_test.exs @@ -0,0 +1,70 @@ +defmodule EctoSync.SyncerTest do + use EctoSync.RepoCase, async: false + alias EctoSync.{Syncer, SyncParams} + + defmodule MockRepo do + def get(schema, id) do + struct(schema, %{id: id}) + end + + def preload(%Post{id: 1} = post, [person: []], _), do: %Post{id: 1, person: %Person{id: 1}} + def preload(%Post{id: 2} = post, [person: []], _), do: %Post{id: 2, person: %Person{id: 2}} + + def preload(any, _preloads, _) do + any + end + end + + setup :do_setup + + describe "inserted" do + test "inserted with an empty list as value" do + sync_params = SyncParams.new({Post, :inserted, {%{id: 1}, 1}}, []) + assert [%Post{id: 1}] == Syncer.sync([], sync_params) + end + + test "inserted with other schema inserts" do + sync_params = SyncParams.new({Person, :inserted, {%{id: 1}, 1}}, []) + assert [%Post{id: 1}, %Person{id: 1}] == Syncer.sync([%Post{id: 1}], sync_params) + end + + test "inserted with other schema inserts and strict does not insert" do + sync_params = SyncParams.new({Person, :inserted, {%{id: 2}, 1}}, strict: true) + assert [%Post{id: 1}] == Syncer.sync([%Post{id: 1}], sync_params) + end + + test "inserted does also call preloads" do + sync_params = + SyncParams.new({Post, :inserted, {%{id: 2}, 1}}, preloads: %{Post => [:person]}) + + assert [%Post{id: 1, person: %Person{id: 1}}, %Post{id: 2, person: %Person{id: 2}}] == + Syncer.sync([%Post{id: 1, person: %Person{id: 1}}], sync_params) + end + end + + describe "updated" do + test "updated with an empty list as value" do + sync_params = SyncParams.new({Post, :updated, {%{id: 1}, 1}}, []) + assert [%Post{id: 1}] == Syncer.sync([], sync_params) + end + end + + defp do_setup(_) do + options = + EctoSync.Options.new( + adapter: nil, + repo: MockRepo, + watchers: + [{Label, :inserted, label: :label}] + |> EctoSync.watchers(Post, + assocs: [:tags, :labels, person: [:favourite_tags]], + extra_columns: [:person_id] + ) + ) + + start_supervised!({Registry, keys: :duplicate, name: EventRegistry}) + start_supervised!({Cachex, :ecto_sync}) + + :persistent_term.put(EctoSync, options) + end +end diff --git a/test/support/adapter_case.exs b/test/support/adapter_case.exs new file mode 100644 index 0000000..11b9e5e --- /dev/null +++ b/test/support/adapter_case.exs @@ -0,0 +1,11 @@ +defmodule EctoSync.AdapterCase do + @moduledoc false + use ExUnit.CaseTemplate + + using do + quote location: :keep do + import EctoSync.RepoCase + import Ecto + end + end +end diff --git a/test/support/adapter_tests.ex b/test/support/adapter_tests.ex new file mode 100644 index 0000000..63f2e31 --- /dev/null +++ b/test/support/adapter_tests.ex @@ -0,0 +1,2 @@ +defmodule EctoSyncTest.AdapterTests do +end diff --git a/test/support/favourite_people.ex b/test/support/favourite_people.ex index b2a0bed..058c2a7 100644 --- a/test/support/favourite_people.ex +++ b/test/support/favourite_people.ex @@ -1,6 +1,6 @@ defmodule FavouritePeople do @moduledoc false - use Ecto.Schema + use Schema schema "favourite_people" do belongs_to(:parent, Person) diff --git a/test/support/favourite_posts.ex b/test/support/favourite_posts.ex index 0c9c504..8b9a260 100644 --- a/test/support/favourite_posts.ex +++ b/test/support/favourite_posts.ex @@ -1,6 +1,6 @@ defmodule FavouritePosts do @moduledoc false - use Ecto.Schema + use Schema schema "favourite_posts" do belongs_to(:person, Person) diff --git a/test/support/favourite_tags.ex b/test/support/favourite_tags.ex index 9629e30..50f4551 100644 --- a/test/support/favourite_tags.ex +++ b/test/support/favourite_tags.ex @@ -1,6 +1,6 @@ defmodule FavouriteTags do @moduledoc false - use Ecto.Schema + use Schema schema "favourite_tags" do belongs_to(:person, Person) diff --git a/test/support/label.ex b/test/support/label.ex index 0cbba30..306839e 100644 --- a/test/support/label.ex +++ b/test/support/label.ex @@ -1,6 +1,6 @@ defmodule Label do @moduledoc false - use Ecto.Schema + use Schema schema "labels" do field(:name, :string) diff --git a/test/support/person.ex b/test/support/person.ex index 0397897..b184cf5 100644 --- a/test/support/person.ex +++ b/test/support/person.ex @@ -1,6 +1,6 @@ defmodule Person do @moduledoc false - use Ecto.Schema + use Schema schema "persons" do field(:name, :string) diff --git a/test/support/post.ex b/test/support/post.ex index 705e51d..d7927bf 100644 --- a/test/support/post.ex +++ b/test/support/post.ex @@ -1,6 +1,6 @@ defmodule Post do @moduledoc false - use Ecto.Schema + use Schema schema "posts" do field(:name, :string) @@ -11,10 +11,14 @@ defmodule Post do many_to_many(:dup_tags, Tag, join_through: PostsTags, - preload_order: [asc: :id], + preload_order: [asc: :name], where: [name: "test"] ) - many_to_many(:labels, Label, join_through: "posts_labels", preload_order: [asc: :id]) + many_to_many(:labels, Label, + join_through: PostsLabels, + preload_order: [asc: :name] + # join_keys: [post_id: :id, label_id: :id] + ) end end diff --git a/test/support/posts_labels.ex b/test/support/posts_labels.ex new file mode 100644 index 0000000..2bf1eba --- /dev/null +++ b/test/support/posts_labels.ex @@ -0,0 +1,10 @@ +defmodule PostsLabels do + @moduledoc false + + use Schema + + schema "posts_labels" do + belongs_to(:post, Post) + belongs_to(:label, Label) + end +end diff --git a/test/support/posts_tags.ex b/test/support/posts_tags.ex index 156772d..a2ffcae 100644 --- a/test/support/posts_tags.ex +++ b/test/support/posts_tags.ex @@ -1,6 +1,6 @@ defmodule PostsTags do @moduledoc false - use Ecto.Schema + use Schema schema "posts_tags" do belongs_to(:post, Post) diff --git a/test/support/schema.ex b/test/support/schema.ex new file mode 100644 index 0000000..c8ef909 --- /dev/null +++ b/test/support/schema.ex @@ -0,0 +1,10 @@ +defmodule Schema do + defmacro __using__(_) do + quote do + use Ecto.Schema + + @primary_key {:id, :binary_id, [autogenerate: true]} + @foreign_key_type :binary_id + end + end +end diff --git a/test/support/tag.ex b/test/support/tag.ex index 6600c42..4de7b71 100644 --- a/test/support/tag.ex +++ b/test/support/tag.ex @@ -1,6 +1,6 @@ defmodule Tag do @moduledoc false - use Ecto.Schema + use Schema schema "tags" do field(:name, :string)