Active Record: Remove element from PostgreSQL array - ruby-on-rails

Assume there's an active record model called Job which has an array column follower_ids. I have already created a scope which allows to fetch all jobs followed by a user:
# Returns all jobs followed by the specified user id
def self.followed_by(user_id)
where(
Arel::Nodes::InfixOperation.new(
'#>',
Job.arel_table[:follower_ids],
Arel.sql("ARRAY[#{user_id}]::bigint[]")
)
)
end
# Retrieve all jobs followed by user with id=1
Job.followed_by(1)
Is there a way to remove specific elements from the follower_ids column using the database (i.e., not looping through the active record objects and manually calling delete/save for each of them)?
For instance, it'd be nice to do something like Job.followed_by(1).remove_follower(1) to remove user with id=1 from all those jobs' follower_ids with just one query.

I ended using the PostgreSQL array_remove function, which allows to remove a value from an array as follows:
user_id = 1
update_query = <<~SQL
follower_ids = ARRAY_REMOVE(follower_ids, :user_id::bigint),
SQL
sql = ActiveRecord::Base.sanitize_sql([update_query, { user_id: user_id }])
Job.followed_by(user_id).update_all(sql)

I think this is really a XY problem caused by the fact that you are using an array column where you should be using a join table.
The main reasons you don't want to use an array are:
If user is deleted you would have to update every row in the jobs table instead of just removing rows in the join table with a cascade or the delete callback.
No referential integrity.
Horrible unreadable queries. Its really just a marginal step up from a comma separated string.
Joins are not really that expensive. "Premature optimzation is the root of all evil".
You can't use ActiveRecord associations with array columns.
Create the join model with rails g model following user:references job:references. And then setup the assocations:
class Job < ApplicationRecord
has_many :followings
has_many :followers,
through: :followings,
source: :user
end
class User < ApplicationRecord
has_many :followings
has_many :followed_jobs,
source: :job,
through: :followings,
class_name: 'Job'
end
To select jobs followed by a user you just do a inner join:
user.followed_jobs
To get jobs that are not followed you do an outer join on followings where the user id is nil or not equal to user_id.
fui = Following.arel_table[:user_id]
Job.left_joins(:followings)
.where(fui.eq(nil).or(fui.not_eq(1)))
If you want to unfollow a job you just remove the row from followings:
Following.find_by(job: job, user: user).destroy
# or
job.followings.find_by(user: user).destroy
# or
user.followings.find_by(job: job).destroy
You can automatically do this with the when the job or user is destroyed with the dependent: option.

Related

How to check if two columns references the same record?

I have 3 tables. User, Task and Tags.
A user have many tasks.
A user have many tags.
A task have many tags.
So for I get this columns:
User: id; email
Tag: id; user_id; name
Task: id; user_id; description
To manage the fact that a task can have many tags, I added a 4th table, taged_tasks, that is a junction table between Tags and Tasks.
This table have 3 columns : id, task_id, tag_id.
I want to add a constraint into my migrations, that checks that task_id and tag_id belongs to the same user.
But I don't find how to do that, is it even possible to implement that constraint in DB? Or is rails hook (like before_create) the only way to do this check? Because I don't want to have 'invalid' record into this taged_tasks table.
It's possible to validate this either at the database level or at the app level. There are multiple ways to do so: in the database, you could add a trigger; in the app, you could use a Rails validation or scope the relation only to tasks/tags of the same user.
With the Rails method, you could do it by defining an actual HABTM relation and scoping it:
class Tag < ApplicationRecord
belongs_to :user
has_and_belongs_to_many :tasks, ->(tag) { where(user_id: tag.user_id) }
end
class Task < ApplicationRecord
belongs_to :user
has_and_belongs_to_many :tags, ->(task) { where(user_id: task.user_id) }
end
This should prevent invalid relations from being created, but I'm not positive on how strong a guarantee it offers. A validation on the join table would be a little simpler:
class TagTask < ApplicationRecord
belongs_to :task
belongs_to :tag
validate do
tag.user == task.user
end
end
The trigger approach is less Rails-y but offers a stronger guarantee that you won't end up with invalid data. This likely has some errors, but something like:
CREATE OR REPLACE FUNCTION check_same_user()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $function$
BEGIN
if (NOT EXISTS (SELECT 1 FROM tags JOIN tasks USING(user_id) WHERE tag_id = new.tag_id AND task_id = new.task_id)) then
raise exception 'task and tag belong to different users!';
end if;
return new;
END;
$function$
;
-- run the above trigger before every insert/update
CREATE TRIGGER ensure_same_user
BEFORE INSERT OR UPDATE
ON tagged_tasks
EXECUTE PROCEDURE check_same_user();

Get all records where the number of associated records is smaller than a certain number

I have a model Occurrence that has many Cleaners through the joint table Assignments. The Occurrence model has a field number_of_cleaners.
How can I find all Occurrences using Active Record (or SQL, Postgres) where the number of assigned Cleaners is smaller than the number specified in occurrences.number_of_cleaners?
This query is to identify the Occurrences where we need to find more Cleaners to assign to the Occurrence).
class Occurrence < ActiveRecord::Base
belongs_to :job
has_many :assignments
has_many :cleaners, through: :assignments
end
class Assignment < ActiveRecord::Base
belongs_to :cleaner
belongs_to :occurrence
end
Just as a side note, previously we just queried for each Occurrence that had no Assignment regardless of occurrences.number_of_cleaners. The query looked like this:
# Select future occurrences which do not have an assignment (and therefore no cleaners) and select one per job ordering by booking time
# The subquery fetches the IDs of all these occurrences
# Then, it runs another query where it gets all the IDs from the subquery and orders the occurrences by booking time
# See http://stackoverflow.com/a/8708460/1076279 for more information on how to perform subqueryes
subquery = Occurrence.future.where.not(id: Assignment.select(:occurrence_id).uniq).select('distinct on (job_id) id').order('occurrences.job_id', 'booking_time')
#occurrences = Occurrence.includes(job: :customer).where("occurrences.id IN (#{subquery.to_sql})").where.not(bundle_first_id: nil).select{ |occurrence| #current_cleaner.unassigned_during?(occurrence.booking_time, occurrence.end_time) }
Instead of joining the tables and doing query, You should implement counter_cache on your models as its more efficient and meant exactly for the purpose.
For more details, check out these links:
Counter Cache in Rails
Three Easy Steps to Using Counter Caches in Rails

Rails has_one association when there are multiple records with foreign_key

If I understand has_one correctly, the foreign key is on the associated record. This means that I could have multiple related records on the "foreign" side. When I use the has_one records to get the associated record, how do I determine which one will be returned?
Here's an example:
class Job < ActiveRecord::Base
has_one :activity_state
end
class ActivityState < ActiveRecord::Base
belongs_to :job
end
Let's say there are 2 rows in the activity_states table that have their foreign key set to Job #1
ActivityState.where(job_id: 1).select(:id, :job_id)
#=> [#<ActivityState id: 1, job_id: 1>, #<ActivityState id: 2, job_id: 1]
When I try to get the activity_state from the JobRequest, how do I determine which one is returned?
JobRequest.find(1).activity_state
#=> #<ActivityState id: 1, job_id: 1>
Right now, it looks like it is returning the first one it finds.
What if I wanted to return the latest one that was created?
As discussed in the comments, the main issue is that your system is creating multiple ActivityState objects for each job. As you mentioned, your original code was doing this:
ActivityState.create(job_id: #job.id)
The problem is that the old ActivityState still contains the job_id for that job. Consequently, when you executed #job.activity_state, you were indeed seeing the first (and old) activity state because it is the first in the database. Specifically, the has_one relationship executes a LIMIT 1 sql clause, so technically the "first" record is dependent on however your database was ordering the records (usually by order of entry)
Normally, for a has_one relationship, if you were to do
#job.activity_state = ActivityState.new(...activity state params...)
Rails attempts to enforce the "one association per record" concept by resetting the "old" associated record's job_id column to null. Your original code was unintentionally circumventing that enforcement. If you change it to this line of code instead, you will allow Rails to work its magic and ensure consistent behaviour with your association.
You should have a has_many association in this case. To return a particular record based on a column, create a method.
class Job < ActiveRecord::Base
has_many :activity_states
def latest_activity_state
self.activity_states.order("updated_at ASC").last # or created_at, depends
end
end

How to actually use has_many :through and set up relationships

So many tutorials on how to set up a has_many :through but not enough on how to actually do it!
I have a Inventories and Requests table joined by Bookings. Example: there could be 3 lenders who have tents in inventory, each of which is requested by 3 other borrowers. What I want to do is for each of the 3 tents in inventory, show that lender the list of 3 borrowers who requested the tent. Then the lender can pick who s/he wants to be the ultimate borrower.
I have thoughts on how this should work, but no idea if it's right, so please give advice on the below! The action is driven all by the Requests controller. Let's run through an example where the Inventories table already has 3 tents, ids [1, 2, 3]. Let's say Borrower Pat submits a Request_ID 1 for a tent.
Am I then supposed to create 3 new Bookings all with Request_ID 1 and then Inventory_ID [1, 2, 3] to get all the conceivable combinations? Something like
Inventory.where(name: "tent").each { |inventory| #request.bookings.create(inventory_id: inventory.id) }
And then is it right to use the Bookings primary key as the foreign key in both the Request and Inventory? Which means that after Borrower Pat submits his request, the bookings_id will be blank until say Lender 2 accepts, at which point bookings_id equals the id that matches the combination of Request_ID 1 and Inventory_ID 2
Now let's say when a Request is posted and a Bookings is made, I email the lender. However, I realized I don't want to bother Lender Taylor if 3 borrowers want her tent over the same time period. I'll just email her the first time, and then the subsequent ones she'll find out about when she logs in to say yes or no. In this situation is it OK to just query the Bookings table in the create action, something like (expanding off above)
-
Inventory.where(name: "tent").each do |inventory|
if !Bookings.find_by_inventory_id(inventory.id).exists?
# If there are no other bookings for this inventory, then create the booking and send an email
#request.bookings.create(inventory_id: inventory.id)
AlertMail.mail_to_lender(inventory).deliver
else
# If there are other bookings for this inventory, do any of those bookings have a request ID where the requested time overlaps with this new request's requested time? If so then just create a booking, don't bother with another email
if Bookings.where(inventory_id: inventory.id).select { |bookings_id| Bookings.find_by_id(bookings_id).request.time overlaps_with current_request.time }.count > 0
#request.bookings.create(inventory_id: inventory.id)
# If there are other bookings for this inventory but without overlapping times, go ahead and send an new email
else
#request.bookings.create(inventory_id: inventory.id)
AlertMail.mail_to_lender(inventory).deliver
end
end
end
Code above is probably flawed, I just want to know the theory of how this is supposed to be working.
Join Table
Firstly, has_many :through works by using a join table - a central table used to identify two different foreign_keys for your other tables. This is what provides the through functionality:
Some trivia for you:
has_and_belongs_to_many tables are called [plural_model_1]_[plural_model_2] and the models need to be in alphabetical order (entries_users)
has_many :through join tables can be called anything, but are typically called [alphabetical_model_1_singular]_[alphabetical_model_2_plural]
--
Models
The has_many :through models are generally constructed as such:
#app/models/inventory.rb
Class Inventory < ActiveRecord::Base
has_many :bookings
has_many :requests, through: :bookings
end
#app/models/booking.rb
Class Booking < ActiveRecord::Base
belongs_to :inventory
belongs_to :request
end
#app/models/request.rb
Class Request < ActiveRecord::Base
has_many :bookings
has_many :requests, through: :bookings
end
--
Code
Your code is really quite bloated - you'll be much better doing something like this:
#app/controllers/inventories_controller.rb
Class InventoriesController < ApplicationController
def action
#tents = Inventory.where name: "tent"
#tents.each do |tent|
booking = Booking.find_or_create_by inventory_id: tend.id
AlertMail.mail_to_lender(tent).deliver if booking.is_past_due?
end
end
end
#app/models/booking.rb
Class Booking < ActiveRecord::Base
def is_past_due?
...logic here for instance method
end
end
Used find_or_create_by
You should only be referencing things once - it's called DRY (don't repeat yourself)
I did a poor job of asking this question. What I wanted to know was how to create the actual associations once everything is set up in the DB and Model files.
If you want to create a record of B that is in a many-to-many relationship with an existing record of A, it's the same syntax of A.Bs.create. What was more important for me, was how to link an A and B that already existed, in which case the answer was A.B_ids += B_id.
Two other things:
More obvious: if you created/ linked something one way, was the other way automatic? And yes, of course. In a many-to-many relationship, if you've done say A.B_ids += B_id, you no longer have to do 'B.A_ids += A_id`.
Less obvious: if A and B are joined by table AB, the primary key of table AB doesn't need to be added to A or B. Rails wants you to worry about the AB table as less as possible, so searches, builds, etc. can all be done by A.B or B.A instead of A.AB.B or B.AB.A

Rails/postgres, 'foreign keys' stored in array to create 1-many association

Can postgres arrays be used to create a one-to-many/has_many association in rails (4)? I am aware that a foreign key type array is not possible.
Example: A task has multiple assignees. Traditionally I would solve this using an association table: tasks->assignees->users. Using arrays, this would not be necessary as multiple 'foreign keys' could be stored.
The following query could then be used to get all tasks assigned to me:
select * from tasks where ? IN tasks.assignees
You won't be able to make Rails aware of this array and use it for associations.
But if you want quicker searching / filtering of Tasks assigned to users you can keep an array of User IDs in the Task object. Otherwise, you'd have to do a JOIN to find all tasks assigned to Alice, in your standard association table.
So the solution is to keep the association table but also duplicate the assignee User ID into the Task object and use that ID list for faster searching / filtering.
You'll need to hook into the after_create and after_destroy lifecycle for the assignee objects and insert new Assignee IDs into the Task record array. And then when an asignee is removed from a task update the array to remove the ID.
See Postgres docs for all the Array operators:
Something like this:
class Task < ActiveRecord::Base
has_many :assignees, :dependent => :destroy
end
class Asignee < ActiveRecord::Base
belongs_to :task
after_create :insert_task_assignee
after_destroy :remove_task_assignee
# assumes that there is a column called assignee_id
# that contains the User ID of the assigned person
private
def insert_task_assignee
# TODO: check for duplicates here - before we naively push it on?
task.assignee_list = task.assignee_list.push(assignee_id)
task.assignee_list.save
end
def remove_task_assignee
id_list = task.assignee_list
id_list.reject! { |candidate_id| candidate_id == assignee_id }
task.assignee_list = id_list
task.assignee_list.save
end
end
# find all tasks that have been assigned Alice & Bob
# this will require the `postgres_ext` gem for Ruby / Postgres array searching
tasks = Task.where.contains(:assignee_list => [alice.id, bob.id]).all

Resources