Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions installer/lib/mix/tasks/phx.new.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 36 additions & 0 deletions installer/lib/phx_new/generator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: [
Expand Down
1 change: 1 addition & 0 deletions installer/lib/phx_new/interactive.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
4 changes: 4 additions & 0 deletions installer/lib/phx_new/project.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions installer/lib/phx_new/single.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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))}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions installer/templates/phx_ecto/data_case.ex.eex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions installer/templates/phx_single/docker-compose.yml.eex
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions installer/templates/usage-rules/ecto.md
Original file line number Diff line number Diff line change
@@ -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`.
33 changes: 31 additions & 2 deletions installer/test/phx_new_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions installer/test/phx_new_umbrella_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions lib/mix/tasks/phx.gen.auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions lib/mix/tasks/phx.gen.auth/migration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
48 changes: 42 additions & 6 deletions priv/templates/phx.gen.auth/context_functions.ex.eex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -235,7 +271,7 @@

nil ->
{:error, :not_found}
end
end<% end %>
end

@doc ~S"""
Expand Down
4 changes: 3 additions & 1 deletion priv/templates/phx.gen.auth/migration.ex.eex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion priv/templates/phx.gen.auth/schema.ex.eex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading