Skip to content

Instantly share code, notes, and snippets.

@SteffenDE
Last active August 13, 2022 12:23
Show Gist options
  • Save SteffenDE/80e066d132e2c9b983e4d891d58c4b55 to your computer and use it in GitHub Desktop.
Save SteffenDE/80e066d132e2c9b983e4d891d58c4b55 to your computer and use it in GitHub Desktop.
Phoenix.Ecto.SQL.Sandbox + Tasks

Phoenix.Ecto.SQL.Sandbox + Tasks

Mix.install([
  {:ecto_sql, "~> 3.8.0"},
  {:ecto_sqlite3, "~> 0.8.0"},
  {:phoenix, "~> 1.6.11"},
  {:phoenix_live_view, "~> 0.17.11"},
  {:phoenix_ecto, "~> 4.4"},
  {:jason, "~> 1.3"},
  {:plug_cowboy, "~> 2.5"}
])

Application.put_env(:phoenix, :json_library, Jason)

Application.put_env(:foo, Repo,
  database: Path.expand(Path.join(__DIR__, "test.db")),
  pool: Ecto.Adapters.SQL.Sandbox
)

Application.put_env(:foo, :ecto_repos, [Repo])

Application.put_env(:foo, Foo.Endpoint,
  server: true,
  live_view: [signing_salt: "aaaaaaaa"],
  http: [
    # Enable IPv6 and bind on all interfaces.
    # Set it to  {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
    ip: {0, 0, 0, 0, 0, 0, 0, 0},
    port: 8001
  ],
  secret_key_base: :crypto.strong_rand_bytes(32) |> Base.encode32()
)

Introduction

This Livebook sets up a sandboxed LiveView environment for exploring an issue with Tasks: phoenixframework/phoenix_ecto#157

Repo

Adapted from: https://github.com/wojtekmach/mix_install_examples/blob/main/ecto_sql.exs

defmodule Repo do
  use Ecto.Repo, otp_app: :foo, adapter: Ecto.Adapters.SQLite3
end

defmodule Migration0 do
  use Ecto.Migration

  def change do
    create table("posts") do
      add(:title, :string)
      timestamps(type: :utc_datetime_usec)
    end
  end
end

defmodule Post do
  use Ecto.Schema

  schema "posts" do
    field(:title, :string)
    timestamps(type: :utc_datetime_usec)
  end
end

Phoenix

Adapted from: https://github.com/wojtekmach/mix_install_examples/blob/main/phoenix_live_view.exs

defmodule Foo do
  defmodule Router do
    use Phoenix.Router
    import Phoenix.LiveView.Router

    import Plug.Conn
    import Phoenix.Controller

    pipeline :browser do
      plug(:accepts, ["html"])
    end

    scope "/", Foo do
      pipe_through([:browser])

      live("/sample", SampleLive, :index)
      live("/sandbox", SandboxLive, :index)
    end
  end

  defmodule Endpoint do
    use Phoenix.Endpoint, otp_app: :foo

    plug(Phoenix.Ecto.SQL.Sandbox)

    @session_options [
      store: :cookie,
      key: "_example_key",
      signing_salt: "1234"
    ]
    plug(Plug.Parsers,
      parsers: [:urlencoded, :multipart, :json],
      pass: ["*/*"],
      json_decoder: Jason
    )

    plug(Plug.Session, @session_options)
    socket("/live", Phoenix.LiveView.Socket)
    plug(Foo.Router)
  end

  defmodule ErrorView do
    use Phoenix.View,
      root: "does/not/matter",
      namespace: Foo

    def render("404.html", _assigns) do
      "Not found"
    end

    def render("500.html", _assigns) do
      "500 - ooooops!"
    end
  end
end
defmodule Foo.SampleLive do
  use Phoenix.LiveView, layout: {__MODULE__, "live.html"}

  @impl true
  def mount(_params, _session, socket) do
    :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
    metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(Repo, self())

    {:ok, assign(socket, :metadata, Phoenix.Ecto.SQL.Sandbox.encode_metadata(metadata))}
  end

  def render("live.html", assigns) do
    ~H"""
    <style>
      html, body {
        margin: 0;
        padding: 0;
      }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/phoenix@1.6.11/priv/static/phoenix.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/phoenix_live_view@0.17.11/priv/static/phoenix_live_view.min.js"></script>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket);
      liveSocket.connect();
    </script>
    <%= @inner_content %>
    """
  end

  @impl true
  def render(assigns) do
    # sandboxed iframe
    ~H"""
    <iframe
      src={Foo.Router.Helpers.sandbox_path(@socket, :index, %{"sandbox" => @metadata})}
      style="width: 100%; height: 100%; border: none;"
    ></iframe>
    """
  end
end
defmodule Foo.SandboxLive do
  use Phoenix.LiveView, layout: {Foo.SampleLive, "live.html"}

  @impl true
  def mount(%{"sandbox" => metadata}, _session, socket) do
    Phoenix.Ecto.SQL.Sandbox.allow(metadata, Ecto.Adapters.SQL.Sandbox)

    {:ok, assign(socket, :data, nil)}
  end

  @impl true
  def handle_event("load", _, socket) do
    data = Repo.all(Post)
    {:noreply, assign(socket, :data, data)}
  end

  def handle_event("load-async", _, socket) do
    target = self()

    Task.start(fn ->
      data = Repo.all(Post)
      send(target, {:data, data})
    end)

    {:noreply, assign(socket, :data, :loading)}
  end

  def handle_event("load-async-allow", _, socket) do
    target = self()

    {:ok, pid} =
      Task.start(fn ->
        receive do
          :ok -> :ok
        end

        data = Repo.all(Post)
        send(target, {:data, data})
      end)

    Ecto.Adapters.SQL.Sandbox.allow(Repo, self(), pid)
    send(pid, :ok)

    {:noreply, assign(socket, :data, :loading)}
  end

  def handle_event("create-post", _params, socket) do
    Repo.insert!(%Post{title: "Hello, World!"})
    {:noreply, socket}
  end

  @impl true
  def handle_info({:data, data}, socket) do
    {:noreply, assign(socket, :data, data)}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div style="padding: 4px 16px; margin: 0 auto; max-width: 800px;">
      <h1>Demo</h1>
      <p>
        Everything that happens on this page is sandboxed.
        You can create a new sandbox by reloading the page.
      </p>

      <hr>

      <p>
        Insert a new post into the database by pressing the "New Post" button.
        Next, either load the data synchronously inside the LiveView, or asynchronously
        using a Task. The task does not use the sandbox and therefore does not return the
        created post.
      </p>

      <button phx-click="load">Load Data (Sync)</button>
      <button phx-click="load-async">Load Data (Task)</button>
      <button phx-click="load-async-allow">Load Data (Allowed Task)</button>

      <button phx-click="create-post">New Post</button>

      <pre style="background: #fafafa; border-radius: 5px; border: gray;">
    <%= inspect(@data, pretty: true) %>
      </pre>
    </div>
    """
  end
end

Main

defmodule Main do
  def main do
    children = [
      Repo,
      Foo.Endpoint
    ]

    _ = Repo.__adapter__().storage_down(Repo.config())
    :ok = Repo.__adapter__().storage_up(Repo.config())

    {:ok, _} = Supervisor.start_link(children, strategy: :one_for_one)
  end
end
{:ok, main_pid} = Main.main()

Ecto.Migrator.run(Repo, [{0, Migration0}], :up, all: true, log_migrations_sql: :debug)

When everything is running, visit http://localhost:8001/sample

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment