Access to model instance up the where chain in Rails - ruby-on-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.

Related

Prevent duplicate has_many records in Rails 5

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

Proper way to do this with ActiveRecord?

Say I have two classes,
Image and Credit
class Image < ActiveRecord::Base
belongs_to :credit
accepts_nested_attributes_for :credit
end
class Credit < ActiveRecord::Base
#has a field called name
has_many :images
end
I want associate a Credit when Image is created, acting a bit like a tag. Essentially, I want behavior like Credit.find_or_create_by_name, but in the client code using Credit, it would be much cleaner if it was just a Create. I can't seem to figure out a way to bake this into the model.
Try this:
class Image < ActiveRecord::Base
belongs_to :credit
attr_accessor :credit_name
after_create { Credit.associate_object(self) }
end
class Credit < ActiveRecord::Base
#has a field called name
has_many :images
def self.associate_object(object, association='images')
credit = self.find_or_create_by_name(object.credit_name)
credit.send(association) << object
credit.save
end
end
Then when you create an image what you can do is something like
Image.create(:attr1 => 'value1', :attr2 => 'value2', ..., :credit_name => 'some_name')
And it will take the name that you feed into the :credit_name value and use it in the after_create callback.
Note that if you decided to have a different object associated with Credit later on (let's say a class called Text), you could do still use this method like so:
class Text < ActiveRecord::Base
belongs_to :credit
attr_accessor :credit_name
before_create { Credit.associate_object(self, 'texts') }
end
Although at that point you probably would want to consider making a SuperClass for all of the classes that belong_to credit, and just having the superclass handle the association. You might also want to look at polymorphic relationships.
This is probably more trouble than it's worth, and is dangerous because it involves overriding the Credit class's initialize method, but I think this might work. My advice to you would be to try the solution I suggested before and ditch those gems or modify them so they can use your method. That being said, here goes nothing:
First you need a way to get at the method caller for the Credit initializer. Let's use a class I found on the web called CallChain, but we'll modify it for our purposes. You would probably want to put this in your lib folder.
class CallChain
require 'active_support'
def self.caller_class
caller_file.split('/').last.chomp('.rb').classify.constantize
end
def self.caller_file(depth=1)
parse_caller(caller(depth+1).first).first
end
private
#Stolen from ActionMailer, where this was used but was not made reusable
def self.parse_caller(at)
if /^(.+?):(\d+)(?::in `(.*)')?/ =~ at
file = Regexp.last_match[1]
line = Regexp.last_match[2].to_i
method = Regexp.last_match[3]
[file, line, method]
end
end
end
Now we need to overwrite the Credit classes initializer because when you make a call to Credit.new or Credit.create from another class (in this case your Image class), it is calling the initializer from that class. You also need to ensure that when you make a call to Credit.create or Credit.new that you feed in :caller_class_id => self.id to the attributes argument since we can't get at it from the initializer.
class Credit < ActiveRecord::Base
#has a field called name
has_many :images
attr_accessor :caller_class_id
def initialize(args = {})
super
# only screw around with this stuff if the caller_class_id has been set
if caller_class_id
caller_class = CallChain.caller_class
self.send(caller_class.to_param.tableize) << caller_class.find(caller_class_id)
end
end
end
Now that we have that setup, we can make a simple method in our Image class which will create a new Credit and setup the association properly like so:
class Image < ActiveRecord::Base
belongs_to :credit
accepts_nested_attributes_for :credit
# for building
def build_credit
Credit.new(:attr1 => 'val1', etc.., :caller_class_id => self.id)
end
# for creating
# if you wanted to have this happen automatically you could make the method get called by an 'after_create' callback on this class.
def create_credit
Credit.create(:attr1 => 'val1', etc.., :caller_class_id => self.id)
end
end
Again, I really wouldn't recommend this, but I wanted to see if it was possible. Give it a try if you don't mind overriding the initialize method on Credit, I believe it's a solution that fits all your criteria.

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.

accepts_nested_attributes_for :reject_if to trigger another method

I've got a multi-level nested form using formtastic_cocoon (jquery version of formtastic).
I am trying to do some validation in the sense of
if value is_numeric do
insert into database
else do
database lookup on text
insert id as association
end
I was hoping tha the accepts_nested_attributes_for would have an :if option, but apparently there is only the :reject_if.
Is there a way to create a validation like I describe as part of the accepts_nested_attributes_for??
-----------------------Updated as per Zubin's Response ---------------------------
I believe Zubin is on the right track with a method, but I can't seem to get it working just right. The method I am using is
def lookup_prereq=(lookup_prereq)
return if lookup_prereq.blank?
case lookup_prereq
when lookup_prereq.is_a?(Numeric) == true
self.task_id = lookup_prereq
else
self.task = Task.find_by_title(lookup_prereq)
end
end
When I trigger this function, the self.task_id is being put in the database as '0' rather than the Task.id.
I'm wondering if I'm missing something else.
I'm not completely sure that the method is actually being called. Shouldn't I need to say
lookup_prereq(attr[:prereq_id)
at some point?
-------------------further edit -----------------------
I think from what I can find that the method is called only if it is named with the same name as the value for the database, therefore I've changed the method to
def completed_task=(completed_task)
Unfortunately this is still resulting in 0 as the value in the database.
Sounds like you need a method in your nested model to handle that, eg:
class Post < ActiveRecord::Base
has_many :comments
accepts_nested_attributes_for :comments
end
class Comment < ActiveRecord::Base
belongs_to :post
belongs_to :author
def lookup_author=(lookup_author)
return if lookup_author.blank?
case lookup_author
when /^\d+$/
self.author_id = lookup_author
else
self.author = Author.find_by_name(lookup_author)
end
end
end

MongoMapper has_many association

I have problem with mongomapper associations. I have one class names User and other named Model. User has many models but...
user = User.first
=> <User ...
user.models
=> []
Model.find_by_user_id(user.id.to_s)
=> <Model ...
Model.find_by_user_id(user.id.to_s).user == user
=> true
Class code (simplified):
class User
include MongoMapper::Document
# some keys definition
many :models
end
class Model
include MongoMapper::Document
# some keys definitions
belongs_to :user
end
What I am doing wrong?
It appears that MM no longer uses String format for the FK column, so
Model.find_by_user_id(user.id.to_s)
should be
Model.find_by_user_id(user.id)
Furthermore, the datatype of the Model.user_id column should be set to
key :user_id, Mongo::ObjectID
When I ran into this problem, I had to delete and recreate my collection to get it to work- in other words I used to have user_id as a String, but it would only "take" when I switched it when I rebuilt my database. Luckily I am working with test data so that was easy enough.
What kind of errors or exceptions are you getting? The code you posted looks fine.
ah, this is poorly documented in the mm docs. You need to do this here:
class User
include MongoMapper::Document
# some keys definition
many :models, :in => :model_ids
end
class Model
include MongoMapper::Document
# some keys definitions
# no belongs_to necessary here
end
You can then add models to your user via:
# use an existing object
u = User.create ...
m = Model.create ...
# and add the model to the user
u.models << m
# don't forget to save
u.save
# you can then check if it worked like so:
# u.model_ids => [ BSON::ID 'your user id']
Hope that helped.

Resources