From d98558edcc4c642913184ad6a6195b6e50989507 Mon Sep 17 00:00:00 2001 From: Frerich Raabe Date: Thu, 13 Nov 2025 00:40:08 +0100 Subject: [PATCH 1/5] Optionally depend on Phoenix LiveView This prepares the introduction of a ready-made Phoenix.LiveView.UploadWriter implementation which makes it easy to process LiveView uploads by streaming them right into the database. --- mix.exs | 4 +++- mix.lock | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 8f97591..9bf2306 100644 --- a/mix.exs +++ b/mix.exs @@ -44,7 +44,9 @@ defmodule PgLargeObjects.MixProject do {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:ex_doc, "~> 0.27", only: :dev, runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, - {:benchee, "~> 1.0", only: [:dev, :test]} + {:benchee, "~> 1.0", only: [:dev, :test]}, + {:phoenix_live_view, "~> 1.1.17", optional: true}, + {:lazy_html, ">= 0.1.0", only: :test} ] end diff --git a/mix.lock b/mix.lock index 773fff4..2c4bea3 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,7 @@ %{ "benchee": {:hex, :benchee, "1.5.0", "4d812c31d54b0ec0167e91278e7de3f596324a78a096fd3d0bea68bb0c513b10", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "5b075393aea81b8ae74eadd1c28b1d87e8a63696c649d8293db7c4df3eb67535"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [: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", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, @@ -9,15 +10,28 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "ecto": {:hex, :ecto, "3.13.4", "27834b45d58075d4a414833d9581e8b7bb18a8d9f264a21e42f653d500dbeeb5", [: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", "5ad7d1505685dfa7aaf86b133d54f5ad6c42df0b4553741a1ff48796736e88b2"}, "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"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, "ex_doc": {:hex, :ex_doc, "0.39.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [:mix], [{:earmark_parser, "~> 1.4.44", [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", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "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"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.17", "1d782b5901cf13b137c6d8c56542ff6cb618359b2adca7e185b21df728fa0c6c", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fa82307dd9305657a8236d6b48e60ef2e8d9f742ee7ed832de4b8bcb7e0e5ed2"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [: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", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, } From 5f33cb474e21dbc42eae49ea71bc0ae05eb6165a Mon Sep 17 00:00:00 2001 From: Frerich Raabe Date: Thu, 13 Nov 2025 20:54:00 +0100 Subject: [PATCH 2/5] Add PgLargeObjects.ConnCase This should be useful for testing LiveView integrations; it also sets up a dummy endpoint and pubsub server. --- config/test.exs | 5 +++++ test/support/conn_case.ex | 39 +++++++++++++++++++++++++++++++++++ test/support/test_endpoint.ex | 6 ++++++ 3 files changed, 50 insertions(+) create mode 100644 test/support/conn_case.ex create mode 100644 test/support/test_endpoint.ex diff --git a/config/test.exs b/config/test.exs index f4a92d0..cc9f376 100644 --- a/config/test.exs +++ b/config/test.exs @@ -11,3 +11,8 @@ config :pg_large_objects, PgLargeObjects.TestRepo, config :pg_large_objects, ecto_repos: [PgLargeObjects.TestRepo] + +config :pg_large_objects, PgLargeObjects.TestEndpoint, + live_view: [signing_salt: "aaaaaaaa"], + secret_key_base: String.duplicate("a", 64), + pubsub_server: PgLargeObjects.TestPubSub diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex new file mode 100644 index 0000000..d059257 --- /dev/null +++ b/test/support/conn_case.ex @@ -0,0 +1,39 @@ +defmodule PgLargeObjects.ConnCase do + @moduledoc """ + Convenience module for creating test cases interacting with a LiveView UI. + """ + use ExUnit.CaseTemplate + + alias Ecto.Adapters.SQL.Sandbox + + using do + quote do + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import Phoenix.LiveViewTest + + import PgLargeObjects.ConnCase + import PgLargeObjects.Fixtures + + alias PgLargeObjects.TestRepo + + # The default endpoint for testing + @endpoint PgLargeObjects.TestEndpoint + end + end + + setup tags do + opts = [shared: not tags[:async]] + + pid = Sandbox.start_owner!(PgLargeObjects.TestRepo, opts) + on_exit(fn -> Sandbox.stop_owner(pid) end) + + start_supervised!({Phoenix.PubSub, name: PgLargeObjects.TestPubSub}) + start_supervised!(PgLargeObjects.TestEndpoint) + + conn = Phoenix.ConnTest.build_conn() + + {:ok, conn: conn} + end +end diff --git a/test/support/test_endpoint.ex b/test/support/test_endpoint.ex new file mode 100644 index 0000000..76af167 --- /dev/null +++ b/test/support/test_endpoint.ex @@ -0,0 +1,6 @@ +defmodule PgLargeObjects.TestEndpoint do + @moduledoc """ + Dummy endpoint for testing purposes. + """ + use Phoenix.Endpoint, otp_app: :pg_large_objects +end From c77aadfa7d2a3dde5b54a37bcb3d3ec982dbf4c8 Mon Sep 17 00:00:00 2001 From: Frerich Raabe Date: Thu, 13 Nov 2025 00:40:27 +0100 Subject: [PATCH 3/5] Add PgLargeObjects.UploadWriter This makes it easy to process LiveView uploads by storing them straight in PostgreSQL large objects. --- lib/pg_large_objects/upload_writer.ex | 83 ++++++++++++++++++++ test/pg_large_objects/upload_writer_test.exs | 81 +++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 lib/pg_large_objects/upload_writer.ex create mode 100644 test/pg_large_objects/upload_writer_test.exs diff --git a/lib/pg_large_objects/upload_writer.ex b/lib/pg_large_objects/upload_writer.ex new file mode 100644 index 0000000..cd079c4 --- /dev/null +++ b/lib/pg_large_objects/upload_writer.ex @@ -0,0 +1,83 @@ +if Code.ensure_loaded?(Phoenix.LiveView.UploadWriter) do + defmodule PgLargeObjects.UploadWriter do + @moduledoc """ + LiveView UploadWriter streaming data to Postgres large object. + + This module can be used with `Phoenix.LiveView.allow_upload/3` to specify + that file uploads by clients should be streamed straight to large objects in + the database. Pass the `:repo` option in the second element of the tuple + returned by the function passed to `:writer` to indicate which database the + object should be created in, e.g.: + + ```elixir + socket + |> allow_upload(:avatar, + accept: :any, + writer: fn _name, _entry, _socket -> + {PgLargeObjects.UploadWriter, repo: MyApp.Repo} + end + ) + ``` + + The object ID of the uploaded file is available in the meta data available + to the callback given to `Phoenix.LiveView.consume_uploaded_entries/3`: + + ```elixir + consume_uploaded_entries(socket, :photo, fn meta, _entry -> + %{object_id: object_id} = meta + + # Store `object_id` in database to retain handle to uploaded data. + + {:ok, nil} + end) + ``` + + See `Phoenix.LiveView.UploadWriter` for further information. + """ + + @behaviour Phoenix.LiveView.UploadWriter + + alias PgLargeObjects.LargeObject + + @impl Phoenix.LiveView.UploadWriter + def init(opts) do + repo = Keyword.fetch!(opts, :repo) + + with {:ok, object} <- LargeObject.create(repo) do + {:ok, %{object_id: object.oid, repo: repo}} + end + end + + @impl Phoenix.LiveView.UploadWriter + def meta(state), do: state + + @impl Phoenix.LiveView.UploadWriter + def write_chunk(data, state) do + %{object_id: object_id, repo: repo} = state + + result = + repo.transaction(fn -> + result = + with {:ok, object} <- LargeObject.open(repo, object_id, mode: :append), + :ok <- LargeObject.write(object, data) do + LargeObject.close(object) + end + + case result do + :ok -> state + {:error, error} -> repo.rollback(error) + end + end) + + case result do + {:ok, state} -> {:ok, state} + {:error, error} -> {:error, error, state} + end + end + + @impl Phoenix.LiveView.UploadWriter + def close(state, _reason) do + {:ok, state} + end + end +end diff --git a/test/pg_large_objects/upload_writer_test.exs b/test/pg_large_objects/upload_writer_test.exs new file mode 100644 index 0000000..1fec5dd --- /dev/null +++ b/test/pg_large_objects/upload_writer_test.exs @@ -0,0 +1,81 @@ +defmodule PgLargeObjects.UploadWriterTest do + use PgLargeObjects.ConnCase, async: false + + alias Ecto.Adapters.SQL.Sandbox + + defmodule PhotoUploadForm do + use Phoenix.LiveView + + def mount(_params, session, socket) do + %{"test_pid" => test_pid} = session + + {:ok, + socket + |> Phoenix.Component.assign(:test_pid, test_pid) + |> allow_upload(:photo, + accept: ~w(.jpg .jpeg), + writer: fn _name, _entry, _socket -> + {PgLargeObjects.UploadWriter, repo: PgLargeObjects.TestRepo} + end + )} + end + + def render(assigns) do + ~H""" +
+ <.live_file_input upload={@uploads.photo} /> + +
+ """ + end + + def handle_event("save", _params, socket) do + %{test_pid: test_pid} = socket.assigns + + [object_id] = + consume_uploaded_entries(socket, :photo, fn meta, _entry -> + %{object_id: object_id} = meta + {:ok, object_id} + end) + + send(test_pid, {:large_object_uploaded, object_id}) + + {:noreply, socket} + end + end + + setup do + # Enable shared mode so that LiveView processes (which run the + # `UploadWriter` code) can access the repo. + Sandbox.mode(PgLargeObjects.TestRepo, {:shared, self()}) + :ok + end + + test "works", %{conn: conn} do + {:ok, view, _html} = + live_isolated(conn, __MODULE__.PhotoUploadForm, session: %{"test_pid" => self()}) + + fake_data = :crypto.strong_rand_bytes(1_048_576) + + view + |> file_input("#upload-form", :photo, [ + %{ + last_modified: 1_594_171_879_000, + name: "photo.jpeg", + content: fake_data, + size: byte_size(fake_data), + type: "image/jpeg" + } + ]) + |> render_upload("photo.jpeg") + + view + |> element("#upload-form") + |> render_submit(%{}) + + receive do + {:large_object_uploaded, object_id} -> + assert get_large_object!(object_id) == fake_data + end + end +end From 3071024790fe4261469b8c5f1d8b378a502cc6bd Mon Sep 17 00:00:00 2001 From: Frerich Raabe Date: Thu, 13 Nov 2025 20:56:46 +0100 Subject: [PATCH 4/5] Mention PgLargeObjects.UploadWriter in documentation --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2b7574c..94aa306 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ PostgreSQL databases. * Random-access reads and writes to data objects via low-level `PgLargeObjects.LargeObject` API. * Extensions to Ecto query DSL for interacting with large objects as part of Ecto queries. +* Ready-made implementation of `Phoenix.LiveView.UploadWriter` for streaming + client uploads straight into the database. ## Why Use Large Objects? From 4f0b86cd0532ca6de66821e8662556a995907b87 Mon Sep 17 00:00:00 2001 From: Frerich Raabe Date: Thu, 13 Nov 2025 21:02:31 +0100 Subject: [PATCH 5/5] Update ChangeLog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30bdf27..7af6bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## v0.2.0 - 2025-11-13 + + * Add `PgLargeObject.UploadWriter` + ## v0.1.3 - 2025-11-13 * Document difference between `Enum.count/1` and `PgLargeObject.LargeObjects.size/1`