Create composable query for many_to_many association - phoenix-framework

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

Related

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.)

Validating Ecto "many-to-many" relationships

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

How to implement `upsert` for PostgreSQL in ActiveRecord?

I have a stream of data, which contains categories. I want to maintain a Category table which should contain every category I encounter exactly once.
I want to implement a id = Category.upsert(name), which should be atomic (of course), and - if possible - not use stored procedures on the DB side.
The upsert gem seems to do just that - I found it while googling to see if "upsert" is a thing.
How about this:
class Category < ActiveRecord::Base
...
class << self
def upsert(name)
transaction { self.find_or_create_by(name: name).id }
end
end
end
You will have to write an ActiveRecordExtension. Check this out.
The code will look something like this
module ActiveRecordExtension
extend ActiveSupport::Concern
def upsert(attributes)
begin
create(attributes)
rescue ActiveRecord::RecordNotUnique, PG::UniqueViolation => e
find_by_primary_key(attributes['primary_key']).
update(attributes)
end
end
end
ActiveRecord::Base.send(:include, ActiveRecordExtension)

Rails append type to join table with has_many :through and association extensions

I have an AR association with extensions in Rails similar to the example presented in this link:
ActiveRecord Association Extensions
has_many :issues, :through => :qbert_issues do
def tracking
where("qbert_issues.kind = ?", "tracking")
end
def blocking
where("qbert_issues.kind = ?", "blocking")
end
end
As shown above, mine is multi-typed... I need to populate a 'kind' column in my join table. Ideally, this should just work:
q = QBert.find(123)
q.issues.tracking << Issue.find(234)
So, what the article suggests is overloading << and doing something like this:
has_many :issues, ... do
...
def <<(issue)
issue.kind = "UserAccount"
proxy_association.owner.issues += [issue]
end
end
Which would be nice, if kind was static.
It looks like I can do this...
has_many :issues, ... do
...
def <<(*args)
issue, kind = args.flatten
issue.kind = kind
proxy_association.owner.issues += [issue]
end
end
Which would allow me to do this at the very least:
q = QBert.find(123)
q.issues.tracking << [Issue.find(234), :tracking]
That doesn't seem very DRY to me...is there a better way? Bonus points if you take into account that the kind accessor is off a join table qbert_issues. I'm guessing I just have to add the association manually through the QBertIssue model directly.
Figured it out...
def <<(issue)
kind = where_values.second.scan(/kind = '(.*)'/).flatten.first
left = proxy_association.reflection.source_reflection
right = proxy_association.reflection.through_reflection
left.active_record.create(left.foreign_key.to_sym => issue.id,
right.foreign_key.to_sym => proxy_association.owner.id,
:kind => kind)
end
Which lets me do:
q = QBert.find(123)
q.issues.tracking << Issue.find(234)
It could be made sufficiently generalized by parsing out the where_values and merging them into the parameters hash.
Pry rocks, by the way :D

More elegant way to write this ActiveRecord query?

I had the following function. It worked, but I don't like the way it looked.
# in user.rb
def awarded_requests
Request.joins('JOIN application ON application.request_id = request.id').where('accepted = true AND application.user_id = ?', self.id)
end
Then I refactored it to something that's clearly an improvement, but probably not simplest possible form:
def awarded_requests
Request.find(self.applications.accepted.map(&:request_id))
end
Can this be simplified further?
If you set up has many relationship, you can filter out those requests by merging a scope.
class User
has_many :applications
def awarded_requests
Request.joins(:applications).merge(applications.accepted)
end
end
Note that applications.accepted is not an array of records but a scope. This is how Active Record represents a part of SQL query internally, therefore it can smartly combine a few of them.

Resources