if Phoenix.Sync.sandbox_enabled?() do defmodule Phoenix.Sync.Sandbox.Postgres.Adapter do @moduledoc false @behaviour Ecto.Adapter @behaviour Ecto.Adapter.Migration @behaviour Ecto.Adapter.Queryable @behaviour Ecto.Adapter.Schema @behaviour Ecto.Adapter.Transaction @behaviour Ecto.Adapter.Storage @adapter Ecto.Adapters.Postgres @driver :postgrex @impl true defmacro __before_compile__(env) do Ecto.Adapters.SQL.__before_compile__(@driver, env) end @impl true defdelegate ensure_all_started(config, type), to: @adapter @impl true defdelegate init(config), to: @adapter @impl true defdelegate checkout(meta, opts, fun), to: @adapter @impl true defdelegate checked_out?(meta), to: @adapter @impl true defdelegate loaders(primitive_type, ecto_type), to: @adapter @impl true defdelegate dumpers(primitive_type, ecto_type), to: @adapter ## Query # prepare(atom :: :all | :update_all | :delete_all, query :: Ecto.Query.t()) @impl true def prepare(:update_all, query) do if stack_id = Phoenix.Sync.Sandbox.stack_id() do {table, schema, source_prefix} = elem(query.sources, 0) [cte, prefix, fields, join, where | returning] = Ecto.Adapters.Postgres.Connection.update_all(query) [_, source_alias] = Regex.run(~r/"#{table}" AS ([a-z0-9]+)/, IO.iodata_to_binary(prefix)) field_names = schema.__schema__(:fields) column_names = Enum.map(field_names, "e_name(to_string(schema.__schema__(:field_source, &1)))) old_join = [ "(SELECT ", Enum.intersperse(column_names, ", "), " FROM ", quote_name(source_prefix, table), " FOR UPDATE) AS old" ] join = case join do [] -> [" FROM " | old_join] join -> [join | [", " | old_join]] end on = Enum.map_intersperse(schema.__schema__(:primary_key), " AND ", fn pk -> ["old.", quote_name(pk), " = ", source_alias, ".", quote_name(pk)] end) where = case where do [] -> [" WHERE ", on] where -> [where | [" AND ", on]] end return_old = Enum.map_intersperse(column_names, ", ", &["old.", &1]) return_new = Enum.map_intersperse(column_names, ", ", &[source_alias, ".", &1]) {returning, original_count} = case returning do [] -> {[" RETURNING ", return_old, ", ", return_new], 0} returning -> count = length(query.select.fields) {[returning | [",", return_old, ",", return_new]], count} end meta = %{ returning: {original_count, Enum.map(field_names, &{:old, &1}) ++ Enum.map(field_names, &{:new, &1})}, schema_meta: %{schema: schema, source: table, prefix: source_prefix}, stack_id: stack_id } {:nocache, {{:update_all_sync, meta}, [cte, prefix, fields, join, where, returning]}} else # disable caching for update_all queries. otherwise any matching query with # the sandbox disabled will override the sandboxed version with {:cache, prepared} <- @adapter.prepare(:update_all, query) do {:nocache, prepared} end end end def prepare(:delete_all, query) do if stack_id = Phoenix.Sync.Sandbox.stack_id() do {table, schema, source_prefix} = elem(query.sources, 0) [cte, prefix, from, as, source_alias, join, where | returning] = Ecto.Adapters.Postgres.Connection.delete_all(query) field_names = schema.__schema__(:fields) column_names = Enum.map(field_names, &to_string(schema.__schema__(:field_source, &1))) return_old = Enum.map_intersperse(column_names, ", ", &[source_alias, ".", quote_name(&1)]) {returning, original_count} = case returning do [] -> {[" RETURNING ", return_old], 0} returning -> count = length(query.select.fields) {[returning | [",", return_old]], count} end meta = %{ returning: {original_count, Enum.map(field_names, &{:old, &1})}, schema_meta: %{schema: schema, source: table, prefix: source_prefix}, stack_id: stack_id } {:nocache, {{:delete_all_sync, meta}, [cte, prefix, from, as, source_alias, join, where | returning]}} else # disable query caching for same reasons as update_all with {:cache, prepared} <- @adapter.prepare(:delete_all, query) do {:nocache, prepared} end end end def prepare(type, query) do @adapter.prepare(type, query) end @impl true def execute( adapter_meta, query_meta, {:nocache, {{:update_all_sync, meta}, _sql}} = query, params, opts ) do with {n, rows} <- @adapter.execute(adapter_meta, query_meta, query, params, opts) do {keep_count, columns} = meta.returning {return, changes} = Enum.reduce(rows, {[], []}, fn row, {return, emit} -> {return_cols, emit_cols} = Enum.split(row, keep_count) {new_row, old_row} = columns |> Enum.zip(emit_cols) |> Enum.reduce({[], []}, fn {{:new, col}, value}, {new_acc, old_acc} -> {[{col, value} | new_acc], old_acc} {{:old, col}, value}, {new_acc, old_acc} -> {new_acc, [{col, value} | old_acc]} end) {[return_cols | return], [{:update, meta.schema_meta, old_row, new_row} | emit]} end) Phoenix.Sync.Sandbox.Producer.emit_changes(meta.stack_id, Enum.reverse(changes)) {n, if(keep_count > 0, do: Enum.reverse(return), else: nil)} end end def execute( adapter_meta, query_meta, {:nocache, {{:delete_all_sync, meta}, _sql}} = query, params, opts ) do with {n, rows} <- @adapter.execute(adapter_meta, query_meta, query, params, opts) do {keep_count, columns} = meta.returning {return, changes} = Enum.reduce(rows, {[], []}, fn row, {return, emit} -> {return_cols, emit_cols} = Enum.split(row, keep_count) old_row = columns |> Enum.zip(emit_cols) |> Enum.reduce([], fn {{:old, col}, value}, old_acc -> [{col, value} | old_acc] end) {[return_cols | return], [{:delete, meta.schema_meta, old_row} | emit]} end) Phoenix.Sync.Sandbox.Producer.emit_changes(meta.stack_id, Enum.reverse(changes)) {n, if(keep_count > 0, do: Enum.reverse(return), else: nil)} end end def execute(adapter_meta, query_meta, query, params, opts) do @adapter.execute(adapter_meta, query_meta, query, params, opts) end @impl true defdelegate stream(adapter_meta, query_meta, query, params, opts), to: @adapter ## Schema @impl true defdelegate autogenerate(type), to: @adapter @impl true def insert_all( adapter_meta, schema_meta, header, rows, on_conflict, returning, placeholders, opts ) do if stack_id = Phoenix.Sync.Sandbox.stack_id() do all_columns = schema_meta.schema.__schema__(:fields) with {count, rows} <- @adapter.insert_all( adapter_meta, schema_meta, header, rows, on_conflict, all_columns, placeholders, opts ) do inserted = Enum.map(rows, fn row -> {:insert, schema_meta, Enum.zip(all_columns, row)} end) Phoenix.Sync.Sandbox.Producer.emit_changes(stack_id, inserted) return_rows = case returning do [] -> nil columns -> # need to keep returning column order Enum.map(inserted, fn {:insert, _, insert} -> Enum.map(columns, &Keyword.fetch!(insert, &1)) end) end {count, return_rows} end else @adapter.insert_all( adapter_meta, schema_meta, header, rows, on_conflict, returning, placeholders, opts ) end end @impl true def insert( adapter_meta, %{source: "schema_migrations"} = schema_meta, params, on_conflict, returning, opts ) do @adapter.insert(adapter_meta, schema_meta, params, on_conflict, returning, opts) end def insert(adapter_meta, schema_meta, params, on_conflict, returning, opts) do if stack_id = Phoenix.Sync.Sandbox.stack_id() do all_columns = schema_meta.schema.__schema__(:fields) with {:ok, inserted} <- @adapter.insert(adapter_meta, schema_meta, params, on_conflict, all_columns, opts) do Phoenix.Sync.Sandbox.Producer.emit_changes(stack_id, [ {:insert, schema_meta, inserted} ]) {:ok, take(inserted, returning)} end else @adapter.insert( adapter_meta, schema_meta, params, on_conflict, returning, opts ) end end defp quote_names(names) do Enum.map_intersperse(names, ?,, "e_name/1) end defp quote_name(nil, name), do: quote_name(name) defp quote_name(prefix, name), do: [quote_name(prefix), ?., quote_name(name)] defp quote_name({prefix, name}) do quote_name(prefix, name) end defp quote_name(name) when is_atom(name) do quote_name(Atom.to_string(name)) end defp quote_name(name) when is_binary(name) do if String.contains?(name, "\"") do raise ArgumentError, "bad literal/field/index/table name #{inspect(name)} (\" is not permitted)" end [?", name, ?"] end defp intersperse_reduce(list, separator, user_acc, reducer, acc \\ []) defp intersperse_reduce([], _separator, user_acc, _reducer, acc), do: {acc, user_acc} defp intersperse_reduce([elem], _separator, user_acc, reducer, acc) do {elem, user_acc} = reducer.(elem, user_acc) {[acc | elem], user_acc} end defp intersperse_reduce([elem | rest], separator, user_acc, reducer, acc) do {elem, user_acc} = reducer.(elem, user_acc) intersperse_reduce(rest, separator, user_acc, reducer, [acc, elem, separator]) end defp returning([]), do: [] defp returning(returning), do: [" RETURNING " | quote_names(returning)] @impl true def update(adapter_meta, schema_meta, fields, params, returning, opts) do if stack_id = Phoenix.Sync.Sandbox.stack_id() do %{source: source, prefix: prefix} = schema_meta # This is adapted from this function: # sql = Ecto.Adapters.Postgres.Connection.update(prefix, source, fields, params, []) {fields, field_values} = :lists.unzip(fields) filter_values = Keyword.values(params) {fields, count} = intersperse_reduce(fields, ", ", 1, fn field, acc -> {[quote_name(field), " = $" | Integer.to_string(acc)], acc + 1} end) {filters_new, _count} = update_filters(params, count, "e_name("new", &1)) {filters_old, _count} = update_filters(params, count, "e_name/1) all_columns = schema_meta.schema.__schema__(:fields) cols = Enum.map_intersperse(all_columns, ?,, "e_name/1) return_all = Enum.map(all_columns, &{:old, &1}) ++ Enum.map(all_columns, &{:new, &1}) # https://stackoverflow.com/a/7927957 # return the old and new values from an update by acquiring an exclusive lock # on the row and using a sub-query. # UPDATE table new # SET value = $1 # FROM (select id, value FROM table WHERE id = $2 FOR UPDATE) old # WHERE new.id = $2 # RETURNING old.id, old.value, new.id, new.value; sql = [ "UPDATE ", quote_name(prefix, source), " new SET ", fields, " FROM (SELECT ", cols, " FROM ", quote_name(prefix, source), " WHERE ", filters_old, " FOR UPDATE) old", " WHERE ", filters_new | returning(return_all) ] with {:ok, updated} <- Ecto.Adapters.SQL.struct( adapter_meta, Ecto.Adapters.Postgres.Connection, sql, :update, source, params, field_values ++ filter_values, :raise, return_all, opts ) do {new, old} = Enum.reduce(updated, {[], []}, fn {{:new, k}, v}, {new, old} -> {[{k, v} | new], old} {{:old, k}, v}, {new, old} -> {new, [{k, v} | old]} end) Phoenix.Sync.Sandbox.Producer.emit_changes(stack_id, [ {:update, schema_meta, old, new} ]) {:ok, take(new, returning)} end else @adapter.update(adapter_meta, schema_meta, fields, params, returning, opts) end end defp update_filters(params, count, quote_fun) do intersperse_reduce(params, " AND ", count, fn {field, nil}, acc -> {[quote_fun.(field), " IS NULL"], acc} {field, _value}, acc -> {[quote_fun.(field), " = $" | Integer.to_string(acc)], acc + 1} end) end @impl true def delete(adapter_meta, schema_meta, params, returning, opts) do if stack_id = Phoenix.Sync.Sandbox.stack_id() do all_columns = schema_meta.schema.__schema__(:fields) with {:ok, deleted} <- @adapter.delete(adapter_meta, schema_meta, params, all_columns, opts) do Phoenix.Sync.Sandbox.Producer.emit_changes(stack_id, [ {:delete, schema_meta, deleted} ]) {:ok, take(deleted, returning)} end else @adapter.delete(adapter_meta, schema_meta, params, returning, opts) end end # basically Keyword.take/2 but preserves the order of the columns defp take(row, columns) do Enum.map(columns, &{&1, Keyword.fetch!(row, &1)}) end ## Transaction @impl true defdelegate transaction(meta, opts, fun), to: @adapter @impl true defdelegate in_transaction?(meta), to: @adapter @impl true defdelegate rollback(meta, value), to: @adapter ## Migration @impl true defdelegate execute_ddl(meta, definition, opts), to: @adapter @impl true defdelegate supports_ddl_transaction?(), to: @adapter @impl true defdelegate lock_for_migrations(meta, opts, fun), to: @adapter ## Storage @impl true defdelegate storage_up(opts), to: @adapter @impl true defdelegate storage_down(opts), to: @adapter @impl true defdelegate storage_status(opts), to: @adapter end end