diff --git a/installer/lib/mix/tasks/phx.new.ex b/installer/lib/mix/tasks/phx.new.ex index f4bdca798f..57860a8ba7 100644 --- a/installer/lib/mix/tasks/phx.new.ex +++ b/installer/lib/mix/tasks/phx.new.ex @@ -25,6 +25,7 @@ defmodule Mix.Tasks.Phx.New do * `postgres` - via https://github.com/elixir-ecto/postgrex * `mysql` - via https://github.com/elixir-ecto/myxql + * `mongodb` - via https://github.com/elixir-mongo/mongodb_ecto * `mssql` - via https://github.com/livehelpnow/tds * `sqlite3` - via https://github.com/elixir-sqlite/ecto_sqlite3 diff --git a/installer/lib/phx_new/generator.ex b/installer/lib/phx_new/generator.ex index 66bdd01fbd..22551b1941 100644 --- a/installer/lib/phx_new/generator.ex +++ b/installer/lib/phx_new/generator.ex @@ -406,6 +406,10 @@ defmodule Phx.New.Generator do {:ecto_sqlite3, Ecto.Adapters.SQLite3, fs_db_config(app, module)} end + defp get_ecto_adapter("mongodb", app, module) do + {:mongodb_ecto, Mongo.Ecto, mongo_db_config(app, module)} + end + defp get_ecto_adapter(db, _app, _mod) do Mix.raise("Unknown database #{inspect(db)}") end @@ -455,6 +459,38 @@ defmodule Phx.New.Generator do ] end + defp mongo_db_config(app, module) do + [ + dev: [ + mongo_url: "mongodb://localhost:27017/#{app}_dev", + pool_size: 10, + stacktrace: true, + show_sensitive_data_on_connection_error: true + ], + test: [ + mongo_url: + {:literal, + ~s|"mongodb://localhost:27017/#{app}_test\#{System.get_env("MIX_TEST_PARTITION")}"|}, + pool_size: 5 + ], + test_setup_all: "", + test_setup: "Mongo.Ecto.truncate(#{inspect(module)}.Repo)", + prod_variables: """ + database_url = + System.get_env("DATABASE_URL") || + raise \""" + environment variable DATABASE_URL is missing. + For example: mongodb://user:pass@host/database + \""" + """, + prod_config: """ + mongo_url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") + """, + binary_id: true + ] + end + defp socket_db_config(app, module, user, pass) do [ dev: [ diff --git a/installer/lib/phx_new/interactive.ex b/installer/lib/phx_new/interactive.ex index 0df7c46f8d..1e41f17ada 100644 --- a/installer/lib/phx_new/interactive.ex +++ b/installer/lib/phx_new/interactive.ex @@ -4,6 +4,7 @@ defmodule Phx.New.Interactive do @databases [ {"postgres", "PostgreSQL (postgrex)"}, {"mysql", "MySQL (myxql)"}, + {"mongodb", "MongoDB (mongodb_ecto)"}, {"mssql", "MSSQL (tds)"}, {"sqlite3", "SQLite3 (ecto_sqlite3)"}, {"none", "None"} diff --git a/installer/lib/phx_new/project.ex b/installer/lib/phx_new/project.ex index 36885b37d1..04be466103 100644 --- a/installer/lib/phx_new/project.ex +++ b/installer/lib/phx_new/project.ex @@ -66,6 +66,10 @@ defmodule Phx.New.Project do Keyword.fetch!(binding, :mailer) end + def mongo?(%Project{opts: opts}) do + Keyword.get(opts, :database) == "mongodb" + end + def verbose?(%Project{opts: opts}) do Keyword.get(opts, :verbose, false) end diff --git a/installer/lib/phx_new/single.ex b/installer/lib/phx_new/single.ex index 333ea5ee6f..cc11a19ee3 100644 --- a/installer/lib/phx_new/single.ex +++ b/installer/lib/phx_new/single.ex @@ -105,6 +105,10 @@ defmodule Phx.New.Single do {:eex, :app, "phx_mailer/lib/app_name/mailer.ex.eex": "lib/:app/mailer.ex"} ]) + template(:docker_compose, [ + {:eex, :project, "phx_single/docker-compose.yml.eex": "docker-compose.yml"} + ]) + def prepare_project(%Project{app: app, base_path: base_path} = project) when not is_nil(app) do if in_umbrella?(base_path) do %{project | in_umbrella?: true, project_path: Path.dirname(Path.dirname(base_path))} @@ -147,6 +151,7 @@ defmodule Phx.New.Single do if Project.html?(project), do: gen_html(project) if Project.mailer?(project), do: gen_mailer(project) if Project.gettext?(project), do: gen_gettext(project) + if Project.mongo?(project), do: gen_docker_compose(project) gen_assets(project) project @@ -186,4 +191,8 @@ defmodule Phx.New.Single do def gen_mailer(%Project{} = project) do copy_from(project, __MODULE__, :mailer) end + + def gen_docker_compose(%Project{} = project) do + copy_from(project, __MODULE__, :docker_compose) + end end diff --git a/installer/templates/phx_ecto/data_case.ex.eex b/installer/templates/phx_ecto/data_case.ex.eex index 3ec498c660..b334c45c14 100644 --- a/installer/templates/phx_ecto/data_case.ex.eex +++ b/installer/templates/phx_ecto/data_case.ex.eex @@ -7,11 +7,16 @@ defmodule <%= @app_module %>.DataCase do your tests. Finally, if the test case interacts with the database, + <% if String.trim(@adapter_config[:test_setup_all]) == "" do %> + we truncate all collections before each test, so changes done + to the database are reverted. + <% else %> we enable the SQL sandbox, so changes done to the database are reverted at the end of every test. If you are using PostgreSQL, you can even run database tests asynchronously by setting `use <%= @app_module %>.DataCase, async: true`, although this option is not recommended for other databases. + <% end %> """ use ExUnit.CaseTemplate diff --git a/installer/templates/phx_single/docker-compose.yml.eex b/installer/templates/phx_single/docker-compose.yml.eex new file mode 100644 index 0000000000..8c3e0eb2fe --- /dev/null +++ b/installer/templates/phx_single/docker-compose.yml.eex @@ -0,0 +1,16 @@ +services: + mongodb: + image: mongo:latest + command: ["--replSet", "rs0", "--bind_ip_all"] + ports: + - "27017:27017" + environment: + MONGO_INITDB_DATABASE: <%= @app_name %>_dev + healthcheck: + test: > + mongosh --quiet --eval + 'try { rs.status() } catch(e) { rs.initiate({_id:"rs0",members:[{_id:0,host:"127.0.0.1:27017"}]}) }' + interval: 5s + timeout: 10s + retries: 5 + start_period: 5s diff --git a/installer/templates/usage-rules/ecto.md b/installer/templates/usage-rules/ecto.md new file mode 100644 index 0000000000..2a034c264a --- /dev/null +++ b/installer/templates/usage-rules/ecto.md @@ -0,0 +1,11 @@ +## MongoDB + +When the project uses `--database mongo` (adapter: `Mongo.Ecto`): + +- **Primary keys must be `:binary_id`** — configured automatically via `config :app, :generators, binary_id: true`. All `mix phx.gen.*` commands honour this automatically; no `--binary-id` flag needed. +- **No SQL joins** — use embedded schemas (`embeds_one`, `embeds_many`) for nested data, or separate queries for associations. +- **Migrations create collections and indexes**, not tables. `create table(:name)` → MongoDB collection. `add :col, :type` → no-op (schema-less). +- **Test isolation** uses `Mongo.Ecto.truncate(Repo)` before each test, not `Ecto.Adapters.SQL.Sandbox`. +- **Connection string** uses `mongo_url:` config key. In production, set `DATABASE_URL` env var to a MongoDB URI (`mongodb://...` or `mongodb+srv://...`). +- **Atlas** — swap `DATABASE_URL` to your Atlas connection string; no code changes needed. +- `:decimal` type is not supported by `mongodb_ecto`. diff --git a/installer/test/phx_new_test.exs b/installer/test/phx_new_test.exs index 4314855c29..73f03b21c7 100644 --- a/installer/test/phx_new_test.exs +++ b/installer/test/phx_new_test.exs @@ -748,6 +748,35 @@ defmodule Mix.Tasks.Phx.NewTest do end) end + test "new with mongodb adapter" do + in_tmp("new with mongodb adapter", fn -> + project_path = Path.join(File.cwd!(), "custom_path") + Mix.Tasks.Phx.New.run([project_path, "--database", "mongodb"]) + + assert_file("custom_path/mix.exs", ":mongodb_ecto") + + assert_file("custom_path/config/dev.exs", [ + ~r/mongo_url:/, + ~r/mongodb:\/\/localhost:27017/ + ]) + + assert_file("custom_path/config/test.exs", [~r/mongo_url:/]) + + assert_file("custom_path/config/runtime.exs", [~r/DATABASE_URL/]) + + assert_file("custom_path/config/config.exs", ~r/generators: \[.*binary_id: true.*\]/) + + assert_file("custom_path/lib/custom_path/repo.ex", "Mongo.Ecto") + + assert_file( + "custom_path/test/support/data_case.ex", + "Mongo.Ecto.truncate" + ) + + assert_file("custom_path/docker-compose.yml", ["mongo:latest", "--replSet", "rs.initiate"]) + end) + end + test "new with invalid database adapter" do in_tmp("new with invalid database adapter", fn -> project_path = Path.join(File.cwd!(), "custom_path") @@ -863,8 +892,8 @@ defmodule Mix.Tasks.Phx.NewTest do in_tmp("new interactive custom", fn -> # path send(self(), {:mix_shell_input, :prompt, "custom_app"}) - # database: sqlite3 (option 4) - send(self(), {:mix_shell_input, :prompt, "4"}) + # database: sqlite3 (option 5) + send(self(), {:mix_shell_input, :prompt, "5"}) # binary_id: yes send(self(), {:mix_shell_input, :prompt, "y"}) # web: API-only (option 3, skips assets prompt) diff --git a/installer/test/phx_new_umbrella_test.exs b/installer/test/phx_new_umbrella_test.exs index 9079135a64..f1b9338cd6 100644 --- a/installer/test/phx_new_umbrella_test.exs +++ b/installer/test/phx_new_umbrella_test.exs @@ -680,6 +680,33 @@ defmodule Mix.Tasks.Phx.New.UmbrellaTest do end) end + test "new with mongodb adapter" do + in_tmp("new with mongodb adapter", fn -> + app = "custom_path" + project_path = Path.join(File.cwd!(), app) + Mix.Tasks.Phx.New.run([project_path, "--umbrella", "--database", "mongodb"]) + + assert_file(app_path(app, "mix.exs"), ":mongodb_ecto") + assert_file(app_path(app, "lib/custom_path/repo.ex"), "Mongo.Ecto") + + assert_file(root_path(app, "config/dev.exs"), [ + ~r/mongo_url:/, + ~r/mongodb:\/\/localhost:27017/ + ]) + + assert_file(root_path(app, "config/test.exs"), [~r/mongo_url:/]) + + assert_file(root_path(app, "config/runtime.exs"), [~r/DATABASE_URL/]) + + assert_file(root_path(app, "config/config.exs"), ~r/generators: \[.*binary_id: true.*\]/) + + assert_file( + app_path(app, "test/support/data_case.ex"), + "Mongo.Ecto.truncate" + ) + end) + end + test "new with invalid database adapter" do in_tmp("new with invalid database adapter", fn -> project_path = Path.join(File.cwd!(), "custom_path") diff --git a/lib/mix/tasks/phx.gen.auth.ex b/lib/mix/tasks/phx.gen.auth.ex index e3a9861be7..6a0afc75a0 100644 --- a/lib/mix/tasks/phx.gen.auth.ex +++ b/lib/mix/tasks/phx.gen.auth.ex @@ -224,6 +224,7 @@ defmodule Mix.Tasks.Phx.Gen.Auth do router_scope: router_scope(context), web_path_prefix: web_path_prefix(schema), test_case_options: test_case_options(ecto_adapter), + mongo_adapter?: ecto_adapter == Mongo.Ecto, live?: Keyword.fetch!(context.opts, :live), datetime_module: datetime_module(schema), datetime_now: datetime_now(schema), diff --git a/lib/mix/tasks/phx.gen.auth/migration.ex b/lib/mix/tasks/phx.gen.auth/migration.ex index 55e7adfd54..c2c9972729 100644 --- a/lib/mix/tasks/phx.gen.auth/migration.ex +++ b/lib/mix/tasks/phx.gen.auth/migration.ex @@ -25,9 +25,11 @@ defmodule Mix.Tasks.Phx.Gen.Auth.Migration do defp column_definition(:email, Ecto.Adapters.Postgres), do: "add :email, :citext, null: false" defp column_definition(:email, Ecto.Adapters.SQLite3), do: "add :email, :string, null: false, collate: :nocase" + defp column_definition(:email, Mongo.Ecto), do: "add :email, :string, null: false" defp column_definition(:email, _), do: "add :email, :string, null: false, size: 160" defp column_definition(:token, Ecto.Adapters.Postgres), do: "add :token, :binary, null: false" + defp column_definition(:token, Mongo.Ecto), do: "add :token, :binary, null: false" defp column_definition(:token, _), do: "add :token, :binary, null: false, size: 32" end diff --git a/priv/templates/phx.gen.auth/context_functions.ex.eex b/priv/templates/phx.gen.auth/context_functions.ex.eex index 1e96da55e1..749b3e90ff 100644 --- a/priv/templates/phx.gen.auth/context_functions.ex.eex +++ b/priv/templates/phx.gen.auth/context_functions.ex.eex @@ -176,17 +176,27 @@ If the token is valid `{<%= schema.singular %>, token_inserted_at}` is returned, otherwise `nil` is returned. """ def get_<%= schema.singular %>_by_session_token(token) do - {:ok, query} = <%= inspect schema.alias %>Token.verify_session_token_query(token) - Repo.one(query) + {:ok, query} = <%= inspect schema.alias %>Token.verify_session_token_query(token)<%= if mongo_adapter? do %> + case Repo.one(query) do + nil -> nil + token_record -> + <%= schema.singular %> = Repo.get(<%= inspect schema.alias %>, token_record.<%= schema.singular %>_id) + <%= schema.singular %> && {%{<%= schema.singular %> | authenticated_at: token_record.authenticated_at}, token_record.inserted_at} + end<% else %> + Repo.one(query)<% end %> end @doc """ Gets the <%= schema.singular %> with the given magic link token. """ def get_<%= schema.singular %>_by_magic_link_token(token) do - with {:ok, query} <- <%= inspect schema.alias %>Token.verify_magic_link_token_query(token), + with {:ok, query} <- <%= inspect schema.alias %>Token.verify_magic_link_token_query(token),<%= if mongo_adapter? do %> + %<%= inspect schema.alias %>Token{} = token_record <- Repo.one(query), + %<%= inspect schema.alias %>{} = <%= schema.singular %> <- Repo.get(<%= inspect schema.alias %>, token_record.<%= schema.singular %>_id), + true <- <%= schema.singular %>.email == token_record.sent_to do + <%= schema.singular %><% else %> {<%= schema.singular %>, _token} <- Repo.one(query) do - <%= schema.singular %> + <%= schema.singular %><% end %> else _ -> nil end @@ -211,7 +221,33 @@ `mix help phx.gen.auth`. """ def login_<%= schema.singular %>_by_magic_link(token) do - {:ok, query} = <%= inspect schema.alias %>Token.verify_magic_link_token_query(token) + {:ok, query} = <%= inspect schema.alias %>Token.verify_magic_link_token_query(token)<%= if mongo_adapter? do %> + + with %<%= inspect schema.alias %>Token{} = token_record <- Repo.one(query), + %<%= inspect schema.alias %>{} = <%= schema.singular %> <- Repo.get(<%= inspect schema.alias %>, token_record.<%= schema.singular %>_id), + true <- <%= schema.singular %>.email == token_record.sent_to do + # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password + if is_nil(<%= schema.singular %>.confirmed_at) and not is_nil(<%= schema.singular %>.hashed_password) do + raise """ + magic link log in is not allowed for unconfirmed users with a password set! + + This cannot happen with the default implementation, which indicates that you + might have adapted the code to a different use case. Please make sure to read the + "Mixing magic link and password registration" section of `mix help phx.gen.auth`. + """ + end + + if is_nil(<%= schema.singular %>.confirmed_at) do + <%= schema.singular %> + |> <%= inspect schema.alias %>.confirm_changeset() + |> update_<%= schema.singular %>_and_delete_all_tokens() + else + Repo.delete!(token_record) + {:ok, {<%= schema.singular %>, []}} + end + else + _ -> {:error, :not_found} + end<% else %> case Repo.one(query) do # Prevent session fixation attacks by disallowing magic links for unconfirmed users with password @@ -235,7 +271,7 @@ nil -> {:error, :not_found} - end + end<% end %> end @doc ~S""" diff --git a/priv/templates/phx.gen.auth/migration.ex.eex b/priv/templates/phx.gen.auth/migration.ex.eex index b1d9ea0488..882d45bed0 100644 --- a/priv/templates/phx.gen.auth/migration.ex.eex +++ b/priv/templates/phx.gen.auth/migration.ex.eex @@ -17,7 +17,9 @@ defmodule <%= inspect schema.repo %>.Migrations.Create<%= Macro.camelize(schema. create table(:<%= schema.table %>_tokens<%= if schema.binary_id do %>, primary_key: false<% end %>) do <%= if schema.binary_id do %> add :id, :binary_id, primary_key: true -<% end %> add :<%= schema.singular %>_id, references(:<%= schema.table %>, <%= if schema.binary_id do %>type: :binary_id, <% end %>on_delete: :delete_all), null: false +<% end %><%= if mongo_adapter? do %> add :<%= schema.singular %>_id, :binary_id, null: false +<% else %> add :<%= schema.singular %>_id, references(:<%= schema.table %>, <%= if schema.binary_id do %>type: :binary_id, <% end %>on_delete: :delete_all), null: false +<% end %> <%= migration.column_definitions[:token] %> add :context, :string, null: false add :sent_to, :string diff --git a/priv/templates/phx.gen.auth/schema.ex.eex b/priv/templates/phx.gen.auth/schema.ex.eex index 6efe510286..13b3a6e054 100644 --- a/priv/templates/phx.gen.auth/schema.ex.eex +++ b/priv/templates/phx.gen.auth/schema.ex.eex @@ -27,7 +27,8 @@ defmodule <%= inspect schema.module %> do """ def email_changeset(<%= schema.singular %>, attrs, opts \\ []) do <%= schema.singular %> - |> cast(attrs, [:email]) + |> cast(attrs, [:email])<%= if mongo_adapter? do %> + |> update_change(:email, &String.downcase/1)<% end %> |> validate_email(opts) end diff --git a/priv/templates/phx.gen.auth/schema_token.ex.eex b/priv/templates/phx.gen.auth/schema_token.ex.eex index 94e5923a06..3df46e7e64 100644 --- a/priv/templates/phx.gen.auth/schema_token.ex.eex +++ b/priv/templates/phx.gen.auth/schema_token.ex.eex @@ -57,13 +57,18 @@ defmodule <%= inspect schema.module %>Token do The token is valid if it matches the value in the database and it has not expired (after @session_validity_in_days). """ - def verify_session_token_query(token) do + def verify_session_token_query(token) do<%= if mongo_adapter? do %> + cutoff = DateTime.add(DateTime.utc_now(), -@session_validity_in_days * 86400, :second) + query = + from token in by_token_and_context_query(token, "session"), + where: token.inserted_at > ^cutoff +<% else %> query = from token in by_token_and_context_query(token, "session"), join: <%= schema.singular %> in assoc(token, :<%= schema.singular %>), where: token.inserted_at > ago(@session_validity_in_days, "day"), select: {%{<%= schema.singular %> | authenticated_at: token.authenticated_at}, token.inserted_at} - +<% end %> {:ok, query} end @@ -111,14 +116,17 @@ defmodule <%= inspect schema.module %>Token do {:ok, decoded_token} -> hashed_token = :crypto.hash(@hash_algorithm, decoded_token) +<%= if mongo_adapter? do %> cutoff = DateTime.add(DateTime.utc_now(), -@magic_link_validity_in_minutes * 60, :second) query = + from token in by_token_and_context_query(hashed_token, "login"), + where: token.inserted_at > ^cutoff +<% else %> query = from token in by_token_and_context_query(hashed_token, "login"), join: <%= schema.singular %> in assoc(token, :<%= schema.singular %>), where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"), where: token.sent_to == <%= schema.singular %>.email, select: {<%= schema.singular %>, token} - - {:ok, query} +<% end %> {:ok, query} :error -> :error @@ -141,11 +149,14 @@ defmodule <%= inspect schema.module %>Token do {:ok, decoded_token} -> hashed_token = :crypto.hash(@hash_algorithm, decoded_token) +<%= if mongo_adapter? do %> cutoff = DateTime.add(DateTime.utc_now(), -@change_email_validity_in_days * 86400, :second) query = + from token in by_token_and_context_query(hashed_token, context), + where: token.inserted_at > ^cutoff +<% else %> query = from token in by_token_and_context_query(hashed_token, context), where: token.inserted_at > ago(@change_email_validity_in_days, "day") - - {:ok, query} +<% end %> {:ok, query} :error -> :error diff --git a/priv/templates/phx.gen.auth/test_cases.exs.eex b/priv/templates/phx.gen.auth/test_cases.exs.eex index 80bb6edfa8..0f983eb447 100644 --- a/priv/templates/phx.gen.auth/test_cases.exs.eex +++ b/priv/templates/phx.gen.auth/test_cases.exs.eex @@ -33,7 +33,7 @@ describe "get_<%= schema.singular %>!/1" do test "raises if id is invalid" do assert_raise Ecto.NoResultsError, fn -> - <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= inspect schema.sample_id %>) + <%= inspect context.alias %>.get_<%= schema.singular %>!(<%= if mongo_adapter? do %>"000000000000000000000000"<% else %><%= inspect schema.sample_id %><% end %>) end end diff --git a/usage-rules/ecto.md b/usage-rules/ecto.md index 3d7fd39f98..377d0cb5de 100644 --- a/usage-rules/ecto.md +++ b/usage-rules/ecto.md @@ -7,3 +7,15 @@ - You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields - Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct - **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied + +## MongoDB (adapter: `Mongo.Ecto`) + +When the project uses `--database mongo`: + +- **Primary keys must be `:binary_id`** — configured automatically via `config :app, :generators, binary_id: true`. All `mix phx.gen.*` commands honour this; no `--binary-id` flag needed. +- **Ecto `join` is not supported** — `mongodb_ecto` does not translate Ecto `join` clauses. Use embedded schemas (`embeds_one`, `embeds_many`) for nested data, or MongoDB aggregations (`$lookup`) via the `mongodb_driver` directly for cross-collection queries. +- **Migrations create collections and indexes**, not tables. `create table(:name)` → MongoDB collection. `add :col, :type` → no-op (schema-less). +- **Test isolation** uses `Mongo.Ecto.truncate(Repo)` before each test (called in `DataCase.setup/1`), not `Ecto.Adapters.SQL.Sandbox`. +- **Connection string** uses `mongo_url:` config key. In production, set `DATABASE_URL` to a MongoDB URI (`mongodb://...` or `mongodb+srv://...`). +- **Atlas** — swap `DATABASE_URL` to your Atlas connection string; no code changes needed. +- `:decimal` type is not supported by `mongodb_ecto`.