Prevent duplicate has_many records in Rails 5 - ruby-on-rails

Given the following models:
class Client < ApplicationRecord
has_many :preferences
validates_associated :preferences
accepts_nested_attributes_for :preferences
end
class Preference < ApplicationRecord
belongs_to :client
validates_uniqueness_of :block, scope: [:day, :client_id]
end
I'm still able to create preferences with duplicate days* when creating a batch of preferences during client creation. This is (seemingly) because the client_id foreign key isn't available when the validates_uniqueness_of validation is run. (*I have an index in place which prevents the duplicate from being saved, but I'd like to catch the error, and return a user friendly error message, before it hits the database.)
Is there any way to prevent this from happening via ActiveRecord validations?
EDIT: This appears to be a known issue.

There's not a super clean way to do this with AR validations when you're batch inserting, but you can do it manually with the following steps.
Make a single query to the database using a Postgresql VALUES list to load any potentially duplicate records.
Compare the records you are about to batch create and pull out any duplicates
Manually generate and return your error message
Step 1 looks a little like this
# Build array of uniq attribute pairs we want to check for
uniq_attrs = new_collection.map do |record|
[
record.day,
record.client_id,
]
end
# santize the values and create a tuple like ('Monday', 5)
values = uniq_attrs.map do |attrs|
safe = attrs.map {|v| ActiveRecord::Base.connection.quote(v)}
"( #{safe.join(",")} )"
end
existing = Preference.where(%{
(day, client_id) in
(#{values.join(",")})
})
# SQL Looks like
# select * from preferences where (day, client_id) in (('Monday',5), ('Tuesday', 3) ....)
Then you can take the collection existing and use it in steps 2 and 3 to pull out your duplicates and generate your error messages.
When I've needed this functionality, I've generally made it a self method off my class, so something like
class Preference < ApplicationRecord
def self.filter_duplicates(collection)
# blah blah blah from above
non_duplicates = collection.reject do |record|
existing.find do |exist|
exist.duplicate?(record)
end
end
[non_duplicates, existing]
end
def duplicate?(record)
record.day == self.day &&
record.client_id = self.client_id
end
end

Related

How to check if associated model has entries in Rails 5?

I have a model RegularOpeningHour(dayOfWeek: integer) that is associated to a model OpeningTime(opens: time, closes: time). RegularOpeningHour has an 1:n relation to OpeningTime, so that a specific day can have many opening times.
(I know that I simply could have one entry with 'opens' and 'closes' included in RegularOpeningHour but for other reasons I need this splitting)
Now I want a open?-Method, that returns whether the business is opened or not. I tried the following in my model file regular_opening_hour.rb:
def open?
RegularOpeningHour.where(dayOfWeek: Time.zone.now.wday).any? { |opening_hour| opening_hour.opening_times.where('? BETWEEN opens AND closes', Time.zone.now).any? }
end
Unforutnately, that doesn't work. Any ideas to solve this?
How about this:
def open?
joins(:opening_times)
.where(dayOfWeek: Time.current.wday)
.where("opens <= :time AND closes >= :time", time: Time.current)
.any?
end
EDIT: Missing ':' in the join
You could create some scopes to make selecting open OpeningTimes and open RegularOpeningHours less clunky. This makes creating the given selection much easier.
class OpeningTime < ApplicationRecord
# ...
belongs_to :regular_opening_hour
def self.open
time = Time.current
where(arel_table[:opens].lteq(time).and(arel_table[:closes].gteq(time)))
end
# ...
end
class RegularOpeningHour < ApplicationRecord
# ...
has_many :opening_times
def self.open
where(
dayOfWeek: Time.current.wday,
id: OpeningTime.select(:regular_opening_hour_id).open,
)
end
# ...
end
def open?
RegularOpeningHour.open.any?
end
Since you have has_many association of RegularOpeningHour to OpeningTime you can use join query like below.:
RegularOpeningHour.joins(:opening_times).where(dayOfWeek: Time.zone.now.wday).where('? BETWEEN opening_times.opens AND opening_times.closes', Time.zone.now).any?

Access to model instance up the where chain in Rails

Is it possible to, within the record found through an association, retain access to the related model instance which found it?
Example:
class Person < ApplicationRecord
has_many :assignments
attr_accessor :info_of_the_moment
end
p = Person.first
p.info_of_the_moment = "I don't want this in the db"
assignment = p.assignments.first
assignment.somehow_get_p.info_of_the_moment # or some such magic!
And/or is there a way to "hang on to" the parameters of a scope and have access to them from within the found model instance? Like:
class Person < ApplicationRecord
has_many :assignments
attr_accessor :info_of_the_moment
scope :fun_assignments, -> (info) { where(fun: true) }
end
class Assignment < ApplicationRecord
belongs_to :person
def get_original_info
# When I was found, info was passed into the scope. What was it?
end
end
You can add your own extension methods to an association and those methods can get at the association's owner through proxy_association:
has_many :things do
def m
# Look at proxy_association.owner in here
end
end
So you could say things like:
class Person < ApplicationRecord
has_many :assignments do
def with_info
info = proxy_association.owner.info_of_the_moment
# Then we wave our hands and some magic happens to encode
# `info` into a properly escaped SQL literal that we can
# toss in a `select` call. If you're working with PostgreSQL
# then JSON would be a reasonable first choice if the info
# was, say, a hash.
#
# The `::jsonb` in the `select` call is there to tell everyone
# that the `info_of_the_moment` column is JSON and should be
# decoded as such by ActiveRecord.
encoded_info = ApplicationRecord.connection.quote(info.to_json)
select("assignments.*, #{encoded_info}::jsonb as info_of_the_moment")
end
end
#...
end
p = Person.first
p.info_of_the_moment = { 'some hash' => 'that does', 'not go in' => 'the database' }
assignment = p.assignments.with_info.first
assignment.info_of_the_moment # And out comes the hash but with stringified keys regardless of the original format.
# These will also include the `info_of_the_moment`
p.assignments.where(...).with_info
p.assignments.with_info.where(...)
Things of note:
All the columns in select show up as methods even when they're not part of the table in question.
You can add "extension" methods to an association by including a block with those methods when calling the association's method.
An SQL SELECT can include values that aren't columns, literals work just fine.
What format you use to tunnel your extra information through the association depends on the underlying database.
If the encoded extra information is large then this can get expensive.
This is admittedly a bit kludgey and brittle so I'd agree with you that rethinking your whole approach is a better idea.

Creating a Rails change log

I am pretty new to rails (and development) and have a requirement to create a change log. Let's say you have an employees table. On that table you have an employee reference number, a first name, and a last name. When either the first name or last name changes, I need to log it to a table somewhere for later reporting. I only need to log the change, so if employee ref 1 changes from Bill to Bob, then I need to put the reference number and first name into a table. The change table can have all the columns that mnight change, but most only be populated with the reference number and the changed field. I don't need the previous value either, just the new one. hope that makes sense.
Looked at gems such as paper trail, but they seem very complicated for what I need. I don't ever need to manipulate the model or move versions etc, I just need to track which fields have changed, when, and by whom.
I'd appreciate your recommendations.
If you insist on building your own changelog, based on your requirements you can do so using a few callbacks. First create your log table:
def up
create_table :employee_change_logs do |t|
t.references :employee
# as per your spec - copy all column definitions from your employees table
end
end
In your Employee model:
class Employee < ActiveRecord::Base
has_many :employee_change_logs
before_update :capture_changed_columns
after_update :log_changed_columns
# capture the changes before the update occurs
def capture_changed_columns
#changed_columns = changed
end
def log_changed_columns
return if #changed_columns.empty?
log_entry = employee_change_logs.build
#changed_columns.each{|c| log_entry.send(:"#{c}=", self.send(c))}
log_entry.save!
end
end
I recommend the gem vestal_versions.
To version an ActiveRecord model, simply add versioned to your class like so:
class User < ActiveRecord::Base
versioned
validates_presence_of :first_name, :last_name
def name
"#{first_name} #{last_name}"
end
end
And use like this:
#user.update_attributes(:last_name => "Jobs", :updated_by => "Tyler")
#user.version # => 2
#user.versions.last.user # => "Tyler"
The first thing we did was put an around filter in the application controller. This was how I get the current_employee into the employee model, which was the challenge, especially for a newbie like me!
around_filter :set_employee_for_log, :if => Proc.new { #current_account &&
#current_account.log_employee_changes? && #current_employee }
def set_employee_for_log
Thread.current[:current_employee] = #current_employee.id
begin
yield
ensure
Thread.current[:current_employee ] = nil
end
end
end
Next, in the employee model I defined which fields I was interested in monitoring
CHECK_FIELDS = ['first_name', 'last_name', 'middle_name']
then I added some hooks to actually capture the changes IF logging is enabled at the account level
before_update :capture_changed_columns
after_update :log_changed_columns, :if => Proc.new { self.account.log_employee_changes? }
def capture_changed_columns
#changed_columns = changed
#changes = changes
end
def log_changed_columns
e = EmployeeChangeLog.new
Employee::CHECK_FIELDS.each do |field|
if self.send("#{field}_changed?")
e.send("#{field}=", self.send(field))
end
end
if e.changed?
e.update_attribute(:account_id, self.account.id)
e.update_attribute(:employee_id, self.id)
e.update_attribute(:employee_ref, self.employee_ref)
e.update_attribute(:user_id, Thread.current[:current_employee])
e.save
else return
end
end
And that;s it. If the account enables it, the app keeps an eye on specific fields and then all changes to those fields are logged to a table, creating an simple audit trail.

Reassigning an ActiveRecord instance and corresponding foreign keys

In Rails/ActiveReocrd is there a way to replace one instance with another such that all the relations/foreign keys get resolved.
I could imagine something like this:
//setup
customer1 = Customer.find(1)
customer2 = Customer.find(2)
//this would be cool
customer1.replace_with(customer2)
supposing customer1 was badly configured and someone had gone and created customer2, not knowing about customer1 it would be nice to be able to quickly set everything to customer 2
So, also this would need to update any foreign keys as well
User belongs_to :customer
Website belongs_to :customer
then any Users/Websites with a foreign key customer_id = 1 would automatically get set to 2 by this 'replace_with' method
Does such a thing exist?
[I can imagine a hack involving Customer.reflect_on_all_associations(:has_many) etc]
Cheers,
J
Something like this could work, although there may be a more proper way:
Updated: Corrected a few errors in the associations example.
class MyModel < ActiveRecord::Base
...
# if needed, force logout / expire session in controller beforehand.
def replace_with (another_record)
# handles attributes and belongs_to associations
attribute_hash = another_record.attributes
attribute_hash.delete('id')
self.update_attributes!(attribute_hash)
### Begin association example, not complete.
# generic way of finding model constants
find_model_proc = Proc.new{ |x| x.to_s.singularize.camelize.constantize }
model_constant = find_model_proc.call(self.class.name)
# handle :has_one, :has_many associations
have_ones = model_constant.reflect_on_all_associations(:has_one).find_all{|i| !i.options.include?(:through)}
have_manys = model_constant.reflect_on_all_associations(:has_many).find_all{|i| !i.options.include?(:through)}
update_assoc_proc = Proc.new do |assoc, associated_record, id|
primary_key = assoc.primary_key_name.to_sym
attribs = associated_record.attributes
attribs[primary_key] = self.id
associated_record.update_attributes!(attribs)
end
have_ones.each do |assoc|
associated_record = self.send(assoc.name)
unless associated_record.nil?
update_assoc_proc.call(assoc, associated_record, self.id)
end
end
have_manys.each do |assoc|
associated_records = self.send(assoc.name)
associated_records.each do |associated_record|
update_assoc_proc.call(assoc, associated_record, self.id)
end
end
### End association example, not complete.
# and if desired..
# do not call :destroy if you have any associations set with :dependents => :destroy
another_record.destroy
end
...
end
I've included an example for how you could handle some associations, but overall this can become tricky.

How do I initialize attributes when I instantiate objects in Rails?

Clients have many Invoices. Invoices have a number attribute that I want to initialize by incrementing the client's previous invoice number.
For example:
#client = Client.find(1)
#client.last_invoice_number
> 14
#invoice = #client.invoices.build
#invoice.number
> 15
I want to get this functionality into my Invoice model, but I'm not sure how to. Here's what I'm imagining the code to be like:
class Invoice < ActiveRecord::Base
...
def initialize(attributes = {})
client = Client.find(attributes[:client_id])
attributes[:number] = client.last_invoice_number + 1
client.update_attributes(:last_invoice_number => client.last_invoice_number + 1)
end
end
However, attributes[:client_id] isn't set when I call #client.invoices.build.
How and when is the invoice's client_id initialized, and when can I use it to initialize the invoice's number? Can I get this logic into the model, or will I have to put it in the controller?
Generate a migration that adds invoices_number column to users table. Then in Invoice model write this:
class Invoice < ActiveRecord::Base
belongs_to :user, :counter_cache => true
...
end
This will automatically increase invoices_count attribute for user once the invoice is created.
how about this:
class Invoice < ActiveRecord::Base
...
def initialize(attributes = {})
super
self.number = self.client.invoices.size + 1 unless self.client.nil?
end
end
Here is some useful discussion on after_initialize per Jonathan R. Wallace's comment above:
http://blog.dalethatcher.com/2008/03/rails-dont-override-initialize-on.html
first of all, you don't need to use the attributes collection, you can just do self.client_id. Better yet, as long as you have a belongs_to :client in your Invoice, you could just do self.client.last_invoice_number. Lastly, you almost always want to raise an exception if an update or create fails, so get used to using update_attributes!, which is a better default choice. (if you have any questions about those points, ask, and I'll go into more detail)
Now that that is out of the way, you ran into a bit of a gotcha with ActiveRecord, initializer methods are almost never the right choice. AR gives you a bunch of methods to hook into whatever point of the lifecycle you need to. These are
after_create
after_destroy
after_save
after_update
after_validation
after_validation_on_create
after_validation_on_update
before_create
before_destroy
before_save
before_update
before_validation
before_validation_on_create
before_validation_on_update
What you probably want is to hook into before_create. Something like this
def before_create
self.number ||= self.client.last_invoice_number + 1 unless self.client
end
What that will do is it will hit up the database for your client, get the last invoice number, increment it by one, and set it as its new number, but only if you haven't already set a number (||= will assign, but only if the left side is nil), and only if you have set a client (or client_id) before the save.

Resources