Validating Ecto "many-to-many" relationships - validation

I'm trying to determine the right way to validate a many-to-many relationship in Ecto 2. I have a Conversation model that needs to have many members, and Users can be part of many conversations, so I've established the models like so:
# User Model
defmodule MyApp.User do
...
schema "users" do
....
many_to_many :conversations, Conversation, join_through: "conversations_users"
...
end
...
end
# Conversation Model
defmodule MyApp.Conversation do
...
schema "conversations" do
has_many :messages, Message
many_to_many :members, User, join_through: "conversations_users"
timestamps()
end
def changeset(struct, _params) do
struct
|> validate_member_count
end
defp validate_member_count(changeset) do
members = Repo.all(assoc(changeset, :members))
valid? = length(members) == 2
if valid? do
add_error(changeset, :members, "foo")
else
changeset
end
end
end
However, I just can't get this to work. I've written a simple test to verify that the validations run correctly, but I keep getting the following error:
# Test
test "fails to validate a conversation with less than two members" do
changeset = Conversation.changeset(%Conversation{}, %{})
{message, []} = changeset.errors[:members]
assert message === "must have at least two members"
end
** (FunctionClauseError) no function clause matching in Ecto.Changeset.add_error/4
I'm having a hard time understanding what I'm doing wrong. It seems like it can't find the function, but I've checked the documentation and it seems like Ecto.Changeset.add_error/4 is definitely right, and the arguments to it seem correct as well.
My best guess is that I need to do something in the validation before calling my custom validator, but I just don't know what I should do.

There are 2 mistakes:
You're passing a MyApp.Conversation to validate_member_count, not an Ecto.Changeset. You can convert an Ecto Schema defining Struct into an Ecto.Changeset using Ecto.Changeset.change/1:
def changeset(struct, _params) do
struct
|> change
|> validate_member_count
end
Ecto.assoc/2 accepts an Ecto Schema Struct, not an Ecto.Changeset. You can access the underlying struct from an Ecto.Changeset using .data:
members = Repo.all(assoc(changeset.data, :members))
Final code:
def changeset(struct, _params) do
struct
|> change
|> validate_member_count
end
defp validate_member_count(changeset) do
members = Repo.all(assoc(changeset.data, :members))
valid? = length(members) == 2
if valid? do
add_error(changeset, :members, "foo")
else
changeset
end
end

Related

Metrics/AbcSize Too High: How do I decrease the ABC in this method?

I have recently started using Rubocop to "standardise" my code, and it has helped me optimise a lot of my code, as well as help me learn a lot of Ruby "tricks". I understand that I should use my own judgement and disable Cops where necessary, but I have found myself quite stuck with the below code:
def index
if params[:filters].present?
if params[:filters][:deleted].blank? || params[:filters][:deleted] == "false"
# if owned is true, then we don't need to filter by admin
params[:filters][:admin] = nil if params[:filters][:admin].present? && params[:filters][:owned] == "true"
# if admin is true, then must not filter by owned if false
params[:filters][:owned] = nil if params[:filters][:owned].present? && params[:filters][:admin] == "false"
companies_list =
case params[:filters][:admin]&.to_b
when true
current_user.admin_companies
when false
current_user.non_admin_companies
end
if params[:filters][:owned].present?
companies_list ||= current_user.companies
if params[:filters][:owned].to_b
companies_list = companies_list.where(owner: current_user)
else
companies_list = companies_list.where.not(owner: current_user)
end
end
else
# Filters for deleted companies
companies_list = {}
end
end
companies_list ||= current_user.companies
response = { data: companies_list.alphabetical.as_json(current_user: current_user) }
json_response(response)
end
Among others, the error that I'm getting is the following:
C: Metrics/AbcSize: Assignment Branch Condition size for index is too high. [<13, 57, 16> 60.61/15]
I understand the maths behind it, but I don't know how to simplify this code to achieve the same result.
Could someone please give me some guidance on this?
Thanks in advance.
Well first and foremost, is this code fully tested, including all the myriad conditions? It's so complex that refactoring will surely be disastrous unless the test suite is rigorous. So, write a comprehensive test suite if you don't already have one. If there's already a test suite, make sure it tests all the conditions.
Second, apply the "fat model skinny controller" paradigm. So move all the complexity into a model, let's call it CompanyFilter
def index
companies_list = CompanyFilter.new(current_user, params).list
response = { data: companies_list.alphabetical.as_json(current_user: current_user) }
json_response(response)
end
and move all those if/then/else statements into the CompanyFilter#list method
tests still pass? great, you'll still get the Rubocop warnings, but related to the CompanyFilter class.
Now you need to untangle all the conditions. It's a bit hard for me to understand what's going on, but it looks as if it should be reducible to a single case statement, with 5 possible outcomes. So the CompanyFilter class might look something like this:
class CompanyFilter
attr_accessors :current_user, :params
def initialize(current_user, params)
#current_user = current_user
#params = params
end
def list
case
when no_filter_specified
{}
when user_is_admin
#current_user.admin_companies
when user_is_owned
# etc
when # other condition
# etc
end
end
private
def no_filter_specified
#params[:filter].blank?
end
def user_is_admin
# returns boolean based on params hash
end
def user_is_owned
# returns boolean based on params hash
end
end
tests still passing? perfect! [Edit] Now you can move most of your controller tests into a model test for the CompanyFilter class.
Finally I would define all the different companies_list queries as scopes on the Company model, e.g.
class Company < ApplicationRecord
# some examples, I don't know what's appropriate in this app
scope :for_user, ->(user){ where("...") }
scope :administered_by, ->(user){ where("...") }
end
When composing database scopes ActiveRecord::SpawnMethods#merge is your friend.
Post.where(title: 'How to use .merge')
.merge(Post.where(published: true))
While it doesn't look like much it lets you programatically compose scopes without overelying on mutating assignment and if/else trees. You can for example compose an array of conditions and merge them together into a single ActiveRecord::Relation object with Array#reduce:
[Post.where(title: 'foo'), Post.where(author: 'bar')].reduce(&:merge)
# => SELECT "posts".* FROM "posts" WHERE "posts"."title" = $1 AND "posts"."author" = $2 LIMIT $3
So lets combine that with a skinny controllers approach where you handle filtering in a seperate object:
class ApplicationFilter
include ActiveModel::Attributes
include ActiveModel::AttributeAssignment
attr_accessor :user
def initialize(**attributes)
super()
assign_attributes(attributes)
end
# A convenience method to both instanciate and apply the filters
def self.call(user, params, scope: model_class.all)
return scope unless params[:filters].present?
scope.merge(
new(
permit_params(params).merge(user: user)
).to_scope
)
end
def to_scope
filters.map { |filter| apply_filter(filter) }
.compact
.select {|f| f.respond_to?(:merge) }
.reduce(&:merge)
end
private
# calls a filter_by_foo method if present or
# defaults to where(key => value)
def apply_filter(attribute)
if respond_to? "filter_by_#{attribute}"
send("filter_by_#{attribute}")
else
self.class.model_class.where(
attribute => send(attribute)
)
end
end
# Convention over Configuration is sexy.
def self.model_class
name.chomp("Filter").constantize
end
# filters the incoming params hash based on the attributes of this filter class
def self.permit_params
params.permit(filters).reject{ |k,v| v.blank? }
end
# provided for modularity
def self.filters
attribute_names
end
end
This uses some of the goodness provided by Rails to setup objects with attributes that will dynamically handle filtering attributes. It looks at the list of attributes you have declared and then slices those off the params and applies a method for that filter if present.
We can then write a concrete implementation:
class CompanyFilter < ApplicationFilter
attribute :admin, :boolean, default: false
attribute :owned, :boolean
private
def filter_by_admin
if admin
user.admin_companies
else
user.non_admin_companies
end
end
# this should be refactored to use an assocation on User
def filter_by_owned
case owned
when nil
nil
when true
Company.where(owner: user)
when false
Company.where.not(owner: user)
end
end
end
And you can call it with:
# scope is optional
#companies = CompanyFilter.call(current_user, params), scope: current_user.companies)

Create composable query for many_to_many association

I'm trying to create a composable ecto query for listing all Document for a specific Contributor. I want the api to look something like this:
Document
|> Document.for_contributor(contributor)
|> Repo.all()
But I'm at a loss as to where to begin. I've been doing composable queries before and in a has_many relation where a Contributor could have many Document I would do something like this:
def for_contributor(query, %Contributor{} = contributor) do
from(document in query, where: document.contributor_id == ^contributor.id)
end
But I'm not sure how I would go about doing something similar but with a many_to_many relation.
What would go in my for_contributor function?
defmodule MyApp.Document do
use Ecto.Schema
import Ecto.Changeset
alias MyApp.Contributor
schema "documents" do
many_to_many(:contributors, Contributor, join_through: "contributors_documents")
timestamps()
end
def for_contributor(query, %Contributor{} = contributor) do
# ???
end
end
My join table looks like this:
defmodule MyApp.Repo.Migrations.CreateContributorsDocuments do
use Ecto.Migration
def change do
create table(:contributors_documents, primary_key: false) do
add :contributor_id, references(:contributors)
add :document_id, references(:documents)
end
end
end
I made it more complicated in my head than it needed to be. I solved it with a simple join.
def for_contributor(query, %Contributor{} = contributor) do
from(
document in query,
join: c in assoc(document, :contributors),
where: c.id == ^contributor.id
)
end

Thin controller / Thick model in Phoenix/Ecto

I'm trying to figure out where to place common functions that I would normally (in Rails/ActiveRecord) put in a model class. Specifically, I have User and Company with a many-to-many relationship between them, but a user has a default_company, which just has a boolean flag on the user_companies join table.
ActiveRecord
class User < ActiveRecord::Base
belongs_to :user_companies
has_many :companies, through: :user_companies
def default_company
# Filter through companies to find the one that I want
end
end
(Note, there's probably an even easier way to do it, but this is the basic idea.)
Ecto
I could do something similar in Ecto, like so:
defmodule MyApp.User do
use MyApp.Web, :model
alias MyApp.{Company, CompaniesUser}
schema "users" do
has_many :companies_users, CompaniesUser, on_delete: :delete_all
many_to_many :companies, Company, join_through: "companies_users"
end
def default_company(%User{} = user) do
from(company in Company,
join: cu in CompaniesUser,
where: cu.company_id == company.id
and cu.user_id == ^user.id
and cu.default_company == true
) |> first() |> Repo.one()
end
end
However, based on my limited experience, this seems incorrect. All the examples I have seen keep the Ecto model very limited, just a bunch of changeset methods and some validation code, but strictly nothing business related. There is talk of keeping your business logic separate from your database logic. I get that and respect it, but most of the examples show putting raw Ecto queries inside a controller or otherwise sprinkling Ecto queries all over your app, and that seems wrong too.
Phoenix 1.3
From what I've read about the upcoming 1.3, it looks like the expectation is that this will be handled with Contexts, or specifically, modules that will allow you to logically group your Ecto schema models along with associated modules that define (manually: you define it) an API to access your persistence layer. So, using my above example, it would be something like:
defmodule MyApp.Account do
alias MyApp.Account.User
alias MyApp.Corporate.{Company, CompaniesUser}
def default_company(%User{} = user) do
from(company in Company,
join: cu in CompaniesUser,
where: cu.company_id == company.id
and cu.user_id == ^user.id
and cu.default_company == true
) |> first() |> Repo.one()
end
end
defmodule MyApp.Account.User do
use MyApp.Web, :model
alias MyApp.Corporate.{Company, CompaniesUser}
schema "users" do
has_many :companies_users, CompaniesUser, on_delete: :delete_all
many_to_many :companies, Company, join_through: "companies_users"
end
end
It has 2 modules, one (MyApp.Account.User) is my raw Ecto schema. The other (MyApp.Account) is the API/entry point for all the other logic in my app, like the controllers.
I guess I like the theory, but I'm worried about trying to figure out what models should go where, like in this example: Does Company belong in the Account context, or do I make a new Corporate context?
(Sorry for asking/answering my own question, but in researching the question I found the info for Phoenix 1.3 and thought I might as well just post for anyone who is interested.)

Put constraint on PhoenixFramework changeset

I have three models in Phoenix Framework, User, Post, Vote. Users can down vote the post only when they have more than 1 point. The user point is calculated by the up votes their posts got from other users.
This is what I defined in my Vote model:
schema "votes" do
field :type, :integer # -1 0 1
belongs_to :user, News.User
belongs_to :post, News.Post
timestamps()
end
Because the user points is not defined in Vote schema, so I can't use validate_change or add_error directly in model, unless I read other models' data to decide whether to add error to changeset, obviously it will be doing too many things in Vote model.
Where should I put the constraint? Controller or model?
Maybe I should place constraint on the database, make sure the user point never get below zero? I found something like trigger. But how will PostgreSQL trigger return their result to the changeset?
Updated (this one works, but I'm not sure if it's the best way)
I tried it in my controller:
def create(conn, %{"vote" => vote_params}, user) do
changeset = user
|> build_assoc(:votes)
|> Vote.changeset(vote_params)
changeset = if user.point < 1 do
Ecto.Changeset.add_error(changeset, :user_id, "You points is not enough.")
end
case Repo.insert(changeset) do
{:ok, vote} ->
conn
|> put_status(:created)
|> put_resp_header("location", vote_path(conn, :show, vote))
|> render("show.json", vote: vote)
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> render(WechatNews.ChangesetView, "error.json", changeset: changeset)
end
end
It's easy, but I have to repeat it in the update action too.
You could do something along the lines of
def changeset(struct, params) do
struct
|> cast([:points], params)
|> validate_required(:points)
end
def point_changeset(struct, params) do
struct
|> changeset(params)
|> check_points
end
defp check_points(changeset) do
if get_field(changeset, :points) < 1 do
add_error(changeset, :points, "too low")
else
changeset
end
end
This would give you a function point_changeset/2, which you can use only when you need to check that a user can do a certain action based on their points. It still calls the main changeset/2 function that has your validations that you want to always run.

An elegant way to validate and process a complex input using changesets

I'm trying to create a small Phoenix application, and having troubles finding the best way to process user input that came to the model from a controller.
I have 2 models and 1 controller:
defmodule MyApp.Post do
use MyApp.Web, :model
schema "posts" do
field :title, :string
field :text, :string
field :comments_count, :integer
has_many :comments, MyApp.Comment
timestamps()
end
end
defmodule MyApp.Comment do
use MyApp.Web, :model
schema "comments" do
field :text, :string
field :parent_path, :string # I want to store comments in a tree, using "Materialized Path" method
belongs_to :post, MyApp.Post
timestamps()
end
defmodule Ops do
# I keep all operations that are related to comments in this module
alias MyApp.{Repo, Comment}
def create_by_user(params) do
# params came straight from the controller. Expected fields are:
# 1. text - text of the comment, required
# 2. post_id - id of the post, required
# 3. parent_id - id of the parent comment, optional
# This function must:
# 1. Validate presence of the text (this is simple)
# 2. Check that post with given "post_id" exists
# 3. If "parent_id" is given:
# 3.1. Check that parent comment exists and belongs to the same post
# 3.2. Based on fields of parent comment, calculate the "parent_path" value of the new comment
# 4. If input is valid, insert a new comment to database and update post's "comments_count" field
end
end
end
defmodule MyApp.CommentController do
use MyApp.Web, :controller
alias MyApp.{Post, Comment}
def create(conn, params) do
case Comment.Ops.create_by_user(params) do
{:ok, comment} -> conn |> put_status(200) |> json("not implemented yet")
{:error, changeset} -> conn |> put_status(422) |> json("not implemented yet")
# Also, in case of error, it would be nice to look into changeset.errors and if post wasn't found, return 404
end
end
end
What is the most elegant implementation of Comment.Ops.create_by_user function?

Resources