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
101 changes: 101 additions & 0 deletions database/avoiding-dangling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
The way to prevent dangling database dependencies with Ecto is to use the `on_delete` option for references (also knows as foreing keys) in migrations. This option allows you to specify how the database should handle associated records when a parent record is deleted. The options available are:

1. `:nothing`: Does nothing. This is the default behavior.
2. `:delete_all`: Deletes all associated records when the parent record is deleted.
3. `:nilify_all`: Sets the foreign key to `nil` in all associated records when the parent record is deleted.
4. `{:nilify, columns}`: Expects a list of atoms. Sets the foreign key to `nil` in the specified columns in all associated records when the parent record is deleted. It is not supported by all databases.
5. `:restrict`: Raises an error if you attempt to delete a parent record that has associated records.

Here's an example of how to use the `on_delete` option in a migration:

```elixir
def change do
create table(:posts) do
# other post fields...
add :user_id, references(:users, on_delete: :delete_all)
end
end
```

In this example, when a user is deleted, all of their associated posts will be automatically deleted as well.

You can also specify the `on_delete` behavior in your schema definitions:

```elixir
schema "posts" do
belongs_to :user, User, on_delete: :restrict
end
```

This configuration will raise an error if you attempt to delete a user who has associated posts.

Using the `on_delete` option in schemas is DISCOURAGED on relational databases according to the Ecto documentation. If you are working with this type of databases, you always should use the `on_delete` option in migrations.

When dealing with deeply nested relational models, careful consideration of foreign key behaviors is essential. Applying `:delete_all` indiscriminately can lead to unintended cascading deletions. Consider the following example, extracted from this [post](https://doriankarter.com/avoiding-data-loss-understanding-the-ondelete-option-in-elixir-migrations/):

```elixir
defmodule BookStore.Repo.Migrations.CreateCustomersOrdersAndMailingAddresses do
use Ecto.Migration

def change do
create table(:customers) do
add :name, :string, null: false
end

create table(:mailing_addresses) do
add :customer_id, references(:customers, on_delete: :delete_all), null: false
add :nickname, :string, null: false
add :address_1, :string, null: false
add :address_2, :string
add :city, :string, null: false
add :province_code, :string, null: false
add :zipcode, :string, null: false
add :country_code, :string, null: false
end

create table(:orders) do
add :name, :string, null: false
add :customer_id, references(:customers, on_delete: :delete_all), null: false
add :mailing_address_id, references(:mailing_address, on_delete: :delete_all), null: false

timestamps()
end

create index(:mailing_addresses, [:customer_id])
create index(:orders, [:customer_id])
create index(:orders, [:mailing_address_id])
end
end
```

In the case above, if a customer deletes a mailing address, any associated orders to that mailing address will be also deleted, which might not be the desired behavior. To address this, the author of the post proposes the two following solutions:

1. **Use `:nilify_all` instead `delete_all`**:
```elixir
# removing the `null: false` from the `mailing_address_id`
# and change `delete_all` to `nilify_all`.
add :mailing_address_id, references(:mailing_addresses, on_delete: :nilify_all)
```

2. **Implement Soft Deletes**:
Add a `deleted_at` timestamp to tables where you want to preserve data:
```elixir
add :deleted_at, :utc_datetime
```
Then, instead of actually deleting records, update this field to mark them as deleted.

Also, soft deletes could be implemented in more complex ways depending on the use case.


Here are some best practices for avoiding dangling database dependencies:

1. **Be Explicit**: Avoid relying on the default `on_delete` behavior (which is `:nothing` in Ecto). Always specify the desired behavior explicitly to prevent confusion and potential data integrity issues.

2. **Consider Data Importance**: Use `:delete_all` for dependent records that don't make sense without their parent (e.g., a user's profile picture). Use `:nilify_all` or `:restrict` for more independent data.

3. **Use Soft Deletes for Critical Data**: For important data that you might need to reference later, consider using soft deletes instead of hard deletes.

4. **Test Deletion Scenarios**: Always test various deletion scenarios to ensure your database behaves as expected in different situations.

5. **Document Your Choices**: Clearly document your decisions regarding deletion behaviors, especially for complex relationships.

162 changes: 162 additions & 0 deletions database/custom-validator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
Custom model validators are used to validate data in your models in ways not provided by Ecto's built-in validation functions. To create a custom model validator with Ecto, you need to write a function that takes the changeset as its first argument and may take additional arguments. These extra arguments are typically an atom or a list of atoms representing the field(s) to be validated. Then, add it to the model's validation pipeline.

```elixir
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset

schema "users" do
field :name, :string
field :email, :string
field :password, :string
field :age, :integer

timestamps()
end

def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :password, :age])
|> validate_required([:name, :email, :password, :age])
|> validate_length(:name, min: 3)
|> validate_format(:email, ~r/@/)
|> validate_min_age(:age, min_age: 18)
end

# Custom model validator to check if the user meets the minimum age requirement
defp validate_min_age(changeset, age_field, opts) when is_atom(age_field) do
with {:is_valid, true} <- {:is_valid, changeset.valid?},
{:age, age} <- {:age, get_field(changeset, age_field)},
{:min_age_valid, true} <- {:min_age_valid, age >= opts[:min_age]} do
changeset
else
{:is_valid, false} ->
changeset

{:min_age_valid, false} ->
add_error(changeset, :age, "must be at least #{opts[:min_age]} years old to register.")
end
end
end
```

A custom model validator should return the changeset in order to work properly on the validation pipeline.

If any of the other preciding validations fail, the custom validator will recieve a changeset with errors, and it should be able to handle it. This is why we call `changeset.valid?/0` in order to check if the changeset has errors or not before proceeding with the validations. If the changeset alredy has errors, we don't need to add perform any validation, just return it. Otherwise, we perform the validations and return the changeset with the errors we found if any.

In the above example, the function `get_field/3` will raise an error if the `age_field` is not present in the changeset. To avoid this, we can use the Ecto `validate_change/3` function. This takes the changeset, the field to be validated, and a function (referred to as the `validator`) as arguments. The `validator` is called only if the field is present in the changeset and is not `nil`. The `validator` must return a list of errors (each error would be appended to the changeset and must be a tuple with the field name and the error message). An empty list means the validation passed and no errors were found.

Here's a rewritten version of our example using `validate_change/3`:

```elixir
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset

schema "users" do
field :name, :string
field :email, :string
field :password, :string
field :age, :integer

timestamps()
end

def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :password, :age])
|> validate_required([:name, :email, :password, :age])
|> validate_length(:name, min: 3)
|> validate_format(:email, ~r/@/)
|> validate_min_age(:age, min_age: 18)
end

# Custom model validator to check if the user meets the minimum age requirement
defp validate_min_age(changeset, age_field, opts) when is_atom(age_field) do
validate_change(changeset, age_field, fn field, value ->
case value >= opts[:min_age] do
true ->
[]

false ->
[{field, "must be at least #{opts[:min_age]} years old to register."}]
end
end)
end
end
```


Another way to perform custom validations is to use the `Ecto.Changeset.prepare_changes/2` function. This takes a changeset and a function as arguments, then runs the provided function before the changes are emitted to the repository. The purpose of this is to perform adjustments on the changeset before insert/update/delete operations. The function passed to `prepare_changes/2` should return the changeset.

The following example from the [Ecto documentation](https://hexdocs.pm/ecto/Ecto.Changeset.html#prepare_changes/2) shows how to use `prepare_changes/2` to update a counter cache (a post's comments count) when a comment is created:

```elixir
def create_comment(comment, params) do
comment
|> cast(params, [:body, :post_id])
|> prepare_changes(fn changeset ->
if post_id = get_change(changeset, :post_id) do
query = from Post, where: [id: ^post_id]
changeset.repo.update_all(query, inc: [comment_count: 1])
end
changeset
end)
end
```

> We retrieve the repo from the comment changeset itself and use update_all to update the
> counter cache in one query. Finally, the original changeset must be returned.

Ideally, validations should not rely on database interactions or validate against the data in the database. Validations that depend on the database are "inherently unsafe" according to the Ecto documentation. This is why Ecto's built-in validations that needs a database to be executed are prefixed with `unsafe_`.

However, if you need to perform this kind of validation, consider moving this logic to another module related to the business logic, such as a context. A really good approach is to use `Ecto.Multi` and transactions. If you need to validate something before a database operation and that validation depends on the database, you could add this validation logic as the first step in a multi instead of adding it to the `changeset/2` function in the model.

Here's an example of how to use this approach:

```elixir
defmodule MyApp.Accounts do
@moduledoc """
Accounts context.
"""

alias Ecto.Multi
alias MyApp.Accounts.User
alias MyApp.Repo

defp validate_unique_username_multi(multi, username) do
Multi.run(multi, :username, fn _repo, _changes ->
case Repo.get_by(User, username: username) do
nil ->
{:ok, username}
_ ->
{:error, "Username already taken."}
end
end)
end

def register_user(attrs) do
changeset = User.changeset(%User{}, attrs)

Multi.new()
|> validate_unique_username_multi(attrs["username"])
|> Multi.insert(:user, changeset)
|> Repo.transaction()
|> case do
{:ok, %{user: user}} ->
{:ok, user}

{:error, _failed_operation, reason, _changes} ->
# handle the error
end
end
end
```

Custom model validators in Ecto allow you to extend validation logic beyond the default capabilities provided by the library, giving you control over how specific data should be validated within your application. However, it's essential to avoid database-dependent validations within your changeset to maintain efficiency and safety.






Loading