From e45e9eba4691f3672c95122eee02ea3e0bcc85a8 Mon Sep 17 00:00:00 2001 From: Akin <157198464+selenil@users.noreply.github.com> Date: Wed, 25 Sep 2024 03:29:48 -0400 Subject: [PATCH 1/5] Add page 17 of the part I --- database/avoiding-dangling.md | 101 ++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 database/avoiding-dangling.md diff --git a/database/avoiding-dangling.md b/database/avoiding-dangling.md new file mode 100644 index 0000000..4481e9a --- /dev/null +++ b/database/avoiding-dangling.md @@ -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. + From 6d31dc6e48b855c35bf5f734aa588d81b3382222 Mon Sep 17 00:00:00 2001 From: Akin <157198464+selenil@users.noreply.github.com> Date: Fri, 27 Sep 2024 02:34:59 -0400 Subject: [PATCH 2/5] Add page 13 of the part I --- database/custom-validator.md | 162 +++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 database/custom-validator.md diff --git a/database/custom-validator.md b/database/custom-validator.md new file mode 100644 index 0000000..7e0cae8 --- /dev/null +++ b/database/custom-validator.md @@ -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. + + + + + + From c3e437c9f5186053fd0c157f62b5ba62fdcc98dc Mon Sep 17 00:00:00 2001 From: Akin <157198464+selenil@users.noreply.github.com> Date: Sat, 28 Sep 2024 01:44:58 -0400 Subject: [PATCH 3/5] Add page 14 of the part 1 --- database/nesting-associations.md | 359 +++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 database/nesting-associations.md diff --git a/database/nesting-associations.md b/database/nesting-associations.md new file mode 100644 index 0000000..eaf439e --- /dev/null +++ b/database/nesting-associations.md @@ -0,0 +1,359 @@ +# Nesting Ecto Associations + +This article assumes you are using a relational database. + +Nesting Ecto Associations refers to the practice of working with nested or hierarchical data structures in Elixir's Ecto library. This often comes up when defining changesets of schemas with multiple associations or querying and inserting deeply related records. Let's break this down into each of those operations. For all the examples below, we will be using the following relationship model: + +- A User has one Profile. +- A User has many Orders. +- A User has many Reviews. +- A User has many Addresses. +- A Product belongs to one Category. +- A Product has many Reviews. +- A Product has many OrderItems (meaning that a Product can be ordered multiple times). +- A Category can have one parent Category (self-referential). +- A Category can have many subcategories (self-referential). +- An Order has many OrderItems. +- An Order has one ShippingAddress. + +## Changesets + +When working with related schemas, we need to be able to handle associations inside their changesets. This includes casting data. For that, we use `cast_assoc/3` or `put_assoc/4` functions from `Ecto.Changeset`. + +`cast_assoc/3` is used to cast and manage associations based on external data that comes from outer sources, such as function parameters or a raw CSV file. By using it, Ecto will validate the sent data against the existing data in the struct. + +`put_assoc/4` is used when we have the associations as structs and the changesets in memory, in other words, when we want to manually insert the associations and the parent already exists. Ecto will use the data as is. + +```elixir +defmodule MyApp.Order do + use Ecto.Schema + import Ecto.Changeset + + schema "orders" do + # order fields + + belongs_to :user, MyApp.User + has_many :order_items, MyApp.OrderItem + + timestamps() + end + + def changeset(order, attrs) do + order + |> cast(attrs, [:status, :total]) + |> put_assoc(:user) + |> cast_assoc(:order_items) + end +end + +defmodule MyApp.OrderItem do + use Ecto.Schema + import Ecto.Changeset + + schema "order_items" do + # order_item fields + + belongs_to :order, MyApp.Order + belongs_to :product, MyApp.Product + + timestamps() + end + + def changeset(order_item, attrs) do + order_item + |> cast(attrs, [:quantity, :price]) + |> put_assoc(:order) + |> put_assoc(:product) + end +end +``` + +## Querying + +Let's say we want to retrieve a user with their orders and addresses. We can do this by using the `Ecto.Query.preload/2` function. This is the most common and efficient way to query nested associations, although it does multiple queries to the database. This is because the final results are PARENT + CHILDREN. + +Note: The `preload/2` function comes from `Ecto.Query` and not from `Ecto.Repo`. The `Ecto.Repo.preload/2` function fetches related data too, but does not have the capabilities to make more complex queries with joins or aggregations that `Ecto.Query.preload/2` has. + +```elixir +defmodule MyApp.Accounts.Users do + alias MyApp.Accounts.User + alias MyApp.Repo + import Ecto.Query + + def get_user_with_orders_and_addresses(user_id) do + query = + from u in User, + where: u.id == ^user_id, + preload: [:orders, :addresses] + + Repo.one(query) + end +end +``` + +In case we want to query data with more than one association, we can go through each level of nesting: + +```elixir +defmodule MyApp.Accounts.Users do + alias MyApp.Accounts.User + alias MyApp.Repo + import Ecto.Query + + def get_user_orders_items(user_id) do + query = + from u in User, + where: u.id == ^user_id, + preload: [ + orders: [:order_items], + ] + + Repo.one(query) + end +end +``` + +We could also use `preload/2` to query self-related data, such as categories in our example. + +```elixir +defmodule MyApp.Categories do + alias MyApp.Categories.Category + alias MyApp.Repo + import Ecto.Query + + def get_category_with_relations(category_id) do + query = + from c in Category, + where: c.id == ^category_id, + preload: [ + :parent, + :subcategories, + ] + + Repo.one(query) + end +end +``` + +A good approach to query nested associations is to use `preload/2` with `assoc/3` and joins, to ensure that it results in a single query run against the database. In this pattern, we use a join using `assoc/3` to bring back the nested association and then use `preload/2` to preload the data. As this uses joins, the results will be PARENT * CHILDREN, which Ecto will take care of transforming into the proper structs with the associations. + +```elixir +defmodule MyApp.Accounts.Users do + alias MyApp.Accounts.User + alias MyApp.Repo + import Ecto.Query + + # This will result in two queries, + # one to bring back the user + # and another to bring back the orders related to that user. + def get_user_orders(user_id) do + query = + from u in User, + where: u.id == ^user_id, + preload: [:orders] + + Repo.one(query) + end + + # This will result in a single query, + # and retrieves the same data as the previous example. + def get_user_orders(user_id) do + query = + from u in User, + where: u.id == ^user_id, + join: o in assoc(u, :orders), + preload: [orders: o] + + Repo.one(query) + end +end +``` + +More complex queries could be accomplished by using the capabilities of `Ecto.Query`, especially joins and aggregations. Let's say we want to retrieve all the products that a user has ordered and that have not been delivered or cancelled yet. To do that, we need to get all the orders the user has placed and the order items that belong to those orders. Then, we need to get the product each order item belongs to. We can do all of this in a single database query, using the power of `Ecto.Query`: + +```elixir +defmodule MyApp.Accounts.Users do + alias MyApp.Accounts.User + alias MyApp.Accounts.Order + alias MyApp.Accounts.OrderItem + alias MyApp.Accounts.Product + alias MyApp.Repo + import Ecto.Query + + def get_user_products(user_id) do + query = + from u in User, + where: u.id == ^user_id, + join: o in Order, on: o.user_id == u.id, + where: o.status == "active", + join: oi in OrderItem, on: oi.order_id == o.id, + join: p in Product, on: p.id == oi.product_id, + select: p + Repo.all(query) + end +end +``` + +Another example of a complex query: this time we want to retrieve all the products that a user has ordered in their entire activity, not only the products for active orders. For each product, we want to know the categories of the product, the reviews that user has made, and the total times the user has ordered that product. Again, we accomplish this in a single database query. + +```elixir +defmodule MyApp.Accounts.Users do + alias MyApp.Accounts.User + alias MyApp.Accounts.Order + alias MyApp.Accounts.OrderItem + alias MyApp.Accounts.Product + alias MyApp.Accounts.Category + alias MyApp.Accounts.Review + alias MyApp.Repo + import Ecto.Query + + # function name shortened for simplicity + def get_user_products(user_id) do + from u in User, + where: u.id == ^user_id, + join: o in assoc(u, :orders), + join: oi in assoc(o, :order_items), + join: p in assoc(oi, :product), + join: c in assoc(p, :category), + left_join: r in Review, on: r.product_id == p.id and r.user_id == ^user_id, + + # Group the results by product ID, category ID, and review ID. + # This ensures that the results are aggregated based on these unique identifiers, + # allowing us to count the total number of orders for each combination + # of product and category + group_by: [p.id, c.id, r.id], + + select: %{ + product: p, + category: c, + reviews: r, + # Count the number of orders associated with the product + total_times_ordered: count(o.id) + } + + Repo.all(query) + end +end +``` + +`Ecto.Query` also has functions for any other type of joins and a lot of useful aggregations. When dealing with complex queries that retrieve deeply nested data, it is a good idea to think first about the query in SQL terms and then translate it to Ecto's query syntax. + +As `Ecto.Query`'s functions are composable, we can use them along with `Repo` via the pipe operator. This allows us to extend queries in a declarative way. In particular, we could write a function like this, which takes the associations we want to preload as an argument: + +```elixir +def get(user_id, associations) do + User + |> preload(associations) + |> Repo.get(user_id) +end + +User.get(id, [:orders]) +``` + +However, this will not result in a single database query because we are not using joins, so take care of that. + +## Inserting + +Ecto allows us to insert new records along with their associations by passing a single map containing the data for both the parent record and associated records. + +```elixir +defmodule MyApp.Accounts.Users do + alias MyApp.Accounts.User + alias MyApp.Accounts.Profile + alias MyApp.Repo + + def create_user(attrs) do + %User{} + |> User.changeset(attrs) + |> Repo.insert() + end + + def create_user_with_profile(%{ + name: "John Doe", + profile: %{ + bio: "Software Engineer" + } + }) +end +``` + +But in most scenarios, we need to break the insertion process into multiple steps to work properly on complex data creation. In those cases, it is crucial to have flexibility to manage related data accordingly. There are two main approaches to take: + +1. Use changesets to build the data we want to insert and its associations. Consider the following example: + +```elixir +defmodule MyApp.Accounts.Users do + alias MyApp.Accounts.User + alias MyApp.Accounts.Profile + alias MyApp.Repo + + def create_user_with_profile(attrs) do + user = User.changeset(%User{}, attrs.user) + profile = Profile.changeset(%Profile{}, attrs.profile) + + user_with_profile = Ecto.Changeset.put_assoc(user, :profile, profile) + Repo.insert(user_with_profile) + end +end +``` + +The `put_assoc/4` function from `Ecto.Changeset` creates or replaces an association within the changeset. The changeset will be validated by Ecto. + +2. Run a transaction to create the parent record and then create the related record referencing the parent. This is how we would do it: + +```elixir +defmodule MyApp.Accounts.Users do + alias MyApp.Accounts.User + alias MyApp.Accounts.Profile + alias MyApp.Repo + + def create_user_with_profile(attrs) do + Repo.transaction(fn -> + user = Repo.insert!(User.changeset(%User{}, attrs.user)) + profile = Ecto.build_assoc(user, :profile, attrs.profile) + Repo.insert!(Profile.changeset(profile)) + end) + end +end +``` + +Here, the `build_assoc/3` function, also from `Ecto.Changeset`, creates a struct for an associated record and automatically sets the foreign key according to the association. Its last argument is the rest of the associated record's data. + +Both approaches in combination with `put_assoc/4` and `build_assoc/3` allow us to insert deeply nested data in an efficient way. Let's take a look at a more complex, real-world example, where we want to create an order with its order items and shipping address. + +```elixir +defmodule MyApp.Orders do + alias MyApp.Order + alias MyApp.OrderItem + alias MyApp.ShippingAddress + alias MyApp.Repo + + def create_order(order_attrs, order_items, shipping_address_attrs) do + Repo.transaction(fn -> + case Repo.insert(Order.changeset(%Order{}, Map.put(order_attrs, :status, "active"))) do + {:ok, order} -> + Enum.map(order_items, fn %{order_item_attrs: attrs, product: product} -> + order_item_changeset = + %OrderItem{} + |> OrderItem.changeset(attrs) + |> put_assoc(:order, order) + |> put_assoc(:product, product) + + Repo.insert!(order_item_changeset) + end) + + shipping_address_changeset = + %ShippingAddress{} + |> Ecto.build_assoc(order, :shipping_address) + |> ShippingAddress.changeset(shipping_address_attrs) + + Repo.insert!(shipping_address_changeset) + + {:ok, %{order: order, order_items: order_items, shipping_address: shipping_address}} + + {:error, changeset} -> + Repo.rollback(changeset) + end + end) + end +end +``` From 36e3c912e00e95fd506ce98d84c0759f6676d301 Mon Sep 17 00:00:00 2001 From: Akin <157198464+selenil@users.noreply.github.com> Date: Sat, 5 Oct 2024 14:48:38 -0400 Subject: [PATCH 4/5] Add page 15 of the part 1 --- database/seed-database.md | 82 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 database/seed-database.md diff --git a/database/seed-database.md b/database/seed-database.md new file mode 100644 index 0000000..e3ad386 --- /dev/null +++ b/database/seed-database.md @@ -0,0 +1,82 @@ +# Seeding Databases with Ecto + +Seeding is the process of inserting initial data into a database, typically for testing or development purposes. To seed a database using Ecto, you need to create a `.exs` file that serves as your seed script. The convention is to place this file inside the `priv/repo` directory. + +> NOTE: For new Phoenix projects, a seed file is already created for you in the `priv/repo` directory. + +In this file, you can write to the database using Ecto as you normally would. For example: + +```elixir +MyApp.Repo.insert!(%MyApp.User{name: "John Doe"}) +``` + +This creates an initial user in the database with the name "John Doe". All your application's models are available here. + +To run the seed file, first ensure you are in a test or development environment, then execute this command: + +```bash +mix run priv/repo/seed.exs # replace with the path to your seed file +``` + +This will run the seed file and populate the database with all the initial data the seed file inserts into it. + +> NOTE: Phoenix projects come with aliases configured in the `mix.exs` file to run the seed file automatically when you run `mix ecto.setup`. + +For writing operations, it's recommended to use the bang functions, such as `insert!`, `update!`, and `delete!`, because they will raise an error if something goes wrong. In a development environment, better error handling could be useful, but this is up to you. + +You may also want to delete all prior data before inserting new data to ensure the state of the database before and after each seed is consistent. This can be done using the `Repo.delete_all/1` function: + +```elixir +MyApp.Repo.delete_all(MyApp.User) +MyApp.Repo.insert!(%MyApp.User{name: "John Doe"}) +``` + +This will delete all users in the database and then insert a new one, ensuring that after each seeding, the database will have a single user with the name "John Doe". In general, it's good practice to call the `Repo.delete_all/1` function for each model you have before creating new records of that model in the seed process. + +Remember that the seed file is just a `.exs` file, so any type of logic you want could be included. For example, you can use `if` statements to insert data only if you're in a specific environment or use a `case` statement for error handling: + +```elixir +# Only insert this data if we are in a test environment +if Mix.env() == :test do + MyApp.Repo.insert!(%MyApp.Post{title: "Test Post"}) +end + +# Manage errors +case MyApp.Repo.insert(%MyApp.User{name: "John Doe"}) do + {:ok, user} -> IO.puts("User created: #{user.name}") + {:error, changeset} -> IO.puts("Error: #{inspect(changeset.errors)}") +end +``` + +You may have multiple seed files in the `priv/repo` directory and manage them as you see fit. For example, it could be useful for your project to have separate seed files for testing and development to avoid having a single file with many environment checks. + +It's also possible to create modules to seed data. The benefit of this approach is the ability to seed data via IEx. The process is quite similar: + +```elixir +defmodule MyApp.Seeder do + alias MyApp.Repo + alias MyApp.Post + + @post_amount 100 + + def insert_posts do + clear() + Enum.each(1..@post_amount, fn number -> + Repo.insert!(%Post{title: "Post #{number}"}) + end) + end + + defp clear do + Repo.delete_all(Post) + end +end +``` + +Then, from IEx: + +```bash +$ iex -S mix +iex(1)> MyApp.Seeder.insert_posts +``` + +Both approaches (seed files and seed modules) make it easy to have an initial data state for your database and, as we've seen, are very flexible to adapt to your needs. From 45ed23f3712a052cdb45892f256a8206d41e5ef3 Mon Sep 17 00:00:00 2001 From: Akin <157198464+selenil@users.noreply.github.com> Date: Sat, 5 Oct 2024 19:15:53 -0400 Subject: [PATCH 5/5] Add page 16 of the part 1 --- database/using-helpers.md | 110 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 database/using-helpers.md diff --git a/database/using-helpers.md b/database/using-helpers.md new file mode 100644 index 0000000..24e95ae --- /dev/null +++ b/database/using-helpers.md @@ -0,0 +1,110 @@ +# Ecto Queries and Helper Functions + +It is good practice to define helper functions that encapsulate common operations related to your Ecto models. This approach makes your code more reusable and easier to maintain. + +Ecto queries are composable, allowing you to create helper functions that return reusable query fragments. These fragments are useful for filtering, ordering, or aggregating data. Let's explore this concept with an example from an e-commerce application. + +Suppose we want to retrieve order products by their "trending score," which is calculated based on various factors. We aim to use this ordering logic in multiple queries across the entire application. We could create a helper function like this: + +```elixir +defmodule MyApp.Products.Queries do + import Ecto.Query, warn: false + + @doc """ + Orders products by a dynamic "trending" score. + The trending score is calculated based on recent orders, ratings, and views. + """ + def order_by_trending(query, days) do + cut_off_date = Date.add(Date.utc_today(), -days) + + from p in query, + left_join: o in assoc(p, :order_items), + left_join: ord in assoc(o, :order), + left_join: v in assoc(p, :product_views), + where: ord.inserted_at >= ^cut_off_date or v.viewed_at >= ^cut_off_date, + group_by: p.id, + select_merge: %{ + trending_score: fragment( + "SUM(CASE WHEN ? >= ? THEN 5 ELSE 0 END) + COUNT(DISTINCT ?) * 3 + COUNT(DISTINCT ?) * 1", + p.average_rating, + 4.0, + ord.id, + v.id + ) + }, + order_by: [desc: :trending_score] + end +end +``` + +We can then use this function in our `MyApp.Products` module: + +```elixir +defmodule MyApp.Products do + alias MyApp.Products.{Product, Queries} + alias MyApp.Repo + import Ecto.Query, warn: false + + def list_by_trending(days) do + query = from(p in Product, where: p.status == "active") + + query + |> Queries.order_by_trending(days) + |> Repo.all() + end + + def is_top_10_trending?(id, days) when is_integer(id) and is_integer(days) and days > 0 do + query = from(p in Product, where: p.status == "active") + + query + |> Queries.order_by_trending(days) + |> limit(10) + |> where([p], p.id == ^id) + |> Repo.exists?() + end +end +``` + +Another example of a helper function in our app could be a query that aggregates sales data by a specified time period (day, week, month, or year). This could be used when generating reports or charts: + +```elixir +defmodule MyApp.Sales.Queries do + import Ecto.Query, warn: false + + @doc """ + Reusable query fragment to aggregate sales data by time period. + Supports daily, weekly, monthly, and yearly aggregations. + """ + def sales_by_period(query, period) when period in ~w(day week month year)a do + from o in query, + group_by: fragment("date_trunc(?, ?)", ^period, o.inserted_at), + order_by: fragment("date_trunc(?, ?)", ^period, o.inserted_at), + select: %{ + period: fragment("date_trunc(?, ?)", ^period, o.inserted_at), + total_sales: sum(o.total_amount), + order_count: count(o.id) + } + end +end + +defmodule MyApp.Sales do + alias MyApp.Sales.{Queries, Sale} + alias MyApp.Repo + + def list_by_day do + Sale + |> Queries.sales_by_period("day") + |> Repo.all() + end + + def list_by_week do + Sale + |> Queries.sales_by_period("week") + |> Repo.all() + end + + # Other time periods... +end +``` + +By creating functions that take a query as an argument and return an extended query, you can easily reuse query fragments throughout your application. This approach enhances code readability, maintainability, and reusability.