Representing a has_many relationship in Rails with an abstract class - ruby-on-rails

I have a relationship which I am having a hard time modelling.
I have a Subscription class which is a regular ActiveRecord model and it can have one or more PaymentSources. The problem however is that a payment source could refer to either a CreditCard or a BankAccount.
Given that these models have very different data associated with them I don't feel as though STI is a good option here. So I was wondering if there is an established or recommended approach for a situation in Rails where a model has_many of another model which is actually an abstraction for 2 or more classes which don't share the same data layout.
Ideally, in this particular example I could say something like subscription.payment_source.default and have it refer to either a CreditCard or a BankAccount depending on what the user had selected as their preferred billing method.

TLDR:
[Updated] After some pondering, I will do Option 2 (the more complete solution) which is future-proof flexible, but if you don't need all of this complexity, I'll do just Option 1.
Option 1:
class Subscription < ApplicationRecord
belongs_to :credit_card
belongs_to :bank_account
def payment_sources
[credit_card, bank_account].compact
end
def default_payment_source
case user.preferred_billing_method # assuming you have an integer column in users table called `preferred_billing_method`
when 0 then credit_card # asssuming 0 means "Credit Card"
when 1 then bank_account # assuming 1 means "Bank Account"
else NotImplementedError
end
end
end
Usage
Subscription.first.default_payment_source
# => returns either `CreditCard` or `BankAccount`, or `nil`
Subscription.first.payment_sources.first
# => returns either `CreditCard` or `BankAccount`, or `nil`
Option 2:
class User < ApplicationRecord
belongs_to :default_payment_source, class_name: 'PaymentSource'
has_many :subscriptions
end
class Subscription < ApplicationRecord
belongs_to :user
has_many :payment_sources_subscriptions
has_many :payment_sources, through: :payment_sources_subscriptions
end
# This is just a join-model
class PaymentSourcesSubscription < ApplicationRecord
belongs_to :subscription
belongs_to :payment_source
validates :subscription, uniqueness: { scope: :payment_source }
end
# this is your "abstract" model for "payment sources"
class PaymentSource < ApplicationRecord
belongs_to :payment_sourceable, polymorphic: true
has_many :payment_sources_subscriptions
has_many :subscriptions, through: :payment_sources_subscriptions
validates :payment_sourceable, uniqueness: true
end
class CreditCard < ApplicationRecord
has_one :payment_source, as: :payment_sourceable
end
class BankAccount < ApplicationRecord
has_one :payment_source, as: :payment_sourceable
end
Usage:
User.first.default_payment_source.payment_sourceable
# => returns either `CreditCard` or `BankAccount`, or `nil`
Subscription.first.payment_sources.first.payment_sourceable
# => returns either `CreditCard` or `BankAccount`, or `nil`

Related

Can a scope that looks up on a polymorphic be turned into an association between the two models?

Suppose an Invoice belongs_to Invoiceable, a polymorphic, being the possible invoiceable_types Subscription, SubscriptioCart and Purchase.
The Invoice table has the columns invoiceable_type and invoiceable_id. So for example, if I want to retrieve all Invoices related to a SubscriptionCart through the polymorphic, I can do Invoice.where(invoiceable_type: "SubscriptionCart").
Now how can I transform such scope into a direct association between Invoice and a SubscriptionCart through the polymorphic?
I've tried adding belongs_to :subscription_cart to the Invoice model, resulting in #invoice.subscription_cart returning nil .
This makes sense as the table invoices doesn't have a column subscription_cart_id (nor should it, as that's why we use the polymorphic).
But how do I specify what to look for in Invoiceable then?
I've tried class_name: :SubscriptionCart and foreign_key: subscription_cart_id but it still returns nil.
Stripped down models:
Invoice model:
class Invoice < ApplicationRecord
belongs_to :invoiceable, polymorphic: true
scope :subscription_cart, -> {
where(invoiceable_type: "SubscriptionCart")
}
end
SubscriptionCart model:
class SubscriptionCart < ApplicationRecord
include ::Invoiceable::Subscription
belongs_to :subscription
has_many :invoices, as: :invoiceable
end
Subscription model:
class Subscription < ApplicationRecord
include ::Invoiceable::Subscription
belongs_to :user
has_many :subscription_carts, dependent: :restrict_with_exception
has_many :invoices, as: :invoiceable, dependent: :restrict_with_exception
end
Invoiceable concern:
module Invoiceable::Subscription
extend ActiveSupport::Concern
include Invoiceable
included do
def attributes_for_invoice_items
{}.tap do |attributes|
attributes["flat_fee"] = plan_invoice_item
attributes["delivery_price"] = delivery_price_item_invoice_item if plan.deliverable?
attributes["setup_fee"] = setup_invoice_item if setup_fee_billing_pending?
attributes["per_unit"] = per_unit_invoice_item if base_plan.per_unit?
end
end
end
end

Can't get STI to act as polymorphic association on model

I have a User model that can have an email and a phone number, both of which are models of their own as they both require some form of verification.
So what I'm trying to do is attach Verification::EmailVerification as email_verifications and Verification::PhoneVerification as phone_verifications, which are both STIs of Verification.
class User < ApplicationRecord
has_many :email_verifications, as: :initiator, dependent: :destroy
has_many :phone_verifications, as: :initiator, dependent: :destroy
attr_accessor :email, :phone
def email
#email = email_verifications.last&.email
end
def email=(email)
email_verifications.new(email: email)
#email = email
end
def phone
#phone = phone_verifications.last&.phone
end
def phone=(phone)
phone_verifications.new(phone: phone)
#phone = phone
end
end
class Verification < ApplicationRecord
belongs_to :initiator, polymorphic: true
end
class Verification::EmailVerification < Verification
alias_attribute :email, :information
end
class Verification::PhoneVerification < Verification
alias_attribute :phone, :information
end
However, with the above setup I get the error uninitialized constant User::EmailVerification. I'm unsure of where I'm going wrong.
How I structure this so that I can access email_verifications and phone_verifications on the User model?
When using STI you don't need (or want) polymorphic associations.
Polymorphic associations are a hack around the object-relational impedance mismatch problem used to setup a single association that points to multiple tables. For example:
class Video
has_many :comments, as: :commentable
end
class Post
has_many :comments, as: :commentable
end
class Comment
belongs_to :commentable, polymorphic: true
end
The reason they should be used sparingly is that there is no referential integrity and there are numerous problems related to joining and eager loading records which STI does not have since you have a "real" foreign key column pointing to a single table.
STI in Rails just uses the fact that ActiveRecord reads the type column to see which class to instantiate when loading records which is also used for polymorphic associations. Otherwise it has nothing to do with polymorphism.
When you setup an association to a STI model you just have to create an association to the base inheritance class and rails will handle resolving the types by reading the type column when it loads the associated records:
class User < ApplicationRecord
has_many :verifications
end
class Verification < ApplicationRecord
belongs_to :user
end
module Verifications
class EmailVerification < ::Verification
alias_attribute :email, :information
end
end
module Verifications
class PhoneVerification < ::Verification
alias_attribute :email, :information
end
end
You should also nest your model in modules and not classes. This is partially due to a bug in module lookup that was not resolved until Ruby 2.5 and also due to convention.
If you then want to create more specific associations to the subtypes of Verification you can do it by:
class User < ApplicationRecord
has_many :verifications
has_many :email_verifications, ->{ where(type: 'Verifications::EmailVerification') },
class_name: 'Verifications::EmailVerification'
has_many :phone_verifications, ->{ where(type: 'Verifications::PhoneVerification') },
class_name: 'Verifications::PhoneVerification'
end
If you want to alias the association user and call it initiator you do it by providing the class name option to the belongs_to association and specifying the foreign key in the has_many associations:
class Verification < ApplicationRecord
belongs_to :initiator, class_name: 'User'
end
class User < ApplicationRecord
has_many :verifications, foreign_key: 'initiator_id'
has_many :email_verifications, ->{ where(type: 'Verifications::EmailVerification') },
class_name: 'Verifications::EmailVerification',
foreign_key: 'initiator_id'
has_many :phone_verifications, ->{ where(type: 'Verifications::PhoneVerification') },
class_name: 'Verifications::PhoneVerification',
foreign_key: 'initiator_id'
end
This has nothing to do with polymorphism though.

Rails: Polymorphic Relationship needed multiple times for one model. How to differentiate? Is there a better way?

I have a complicated relationship where I have multiple models require addresses. This usually means using a polymorphic relationship like so:
class Address < ApplicationRecord
belongs_to :addressable, polymorphic: true
end
class User < ApplicationRecord
# no issue, its "addressable" so just use this line of code
has_one :address, as: :addressable
end
class Account < ApplicationRecord
# Ok the issue here is that I need exactly TWO addresses though
# One is for billing and one if a physical address where an event will
# physically take place.
has_one :billing_address, class_name: "Address", as: :addressable
has_one :site_address, class_name: "Address", as: :addressable
end
The problem with this is ...
a = Account.first
a.billing_address #returns an address
a.site_address #returns the same address
How can I get the account to differentiate between two addresses? I know this isn't really a limitation of polymorphism but rather a software design problem that I need to solve. I'm wondering if maybe I need to treat Address as an abstract model and derive BillingAddress and SiteAddress from it and maybe have something like this:
class Address < ApplicationRecord
# see active_record-acts_as gem for how this mixin works
# https://github.com/hzamani/active_record-acts_as
actable
belongs_to :addressable, polymorphic: true
end
class User < ApplicationRecord
# no issue, its "addressable" so just use this line of code
has_one :address, as: :addressable
end
class BillingAddress < ApplicationRecord
acts_as :address
end
class SiteAddress < ApplicationRecord
acts_as :address
end
class Account < ApplicationRecord
has_one :billing_address
has_one :site_address
end
This might be good to do because I also have an Event model which requires a site address so I could do this as well:
class Event < ApplicationRecord
has_one :site_address
end
Is this over engineering? At the risk of sounding too subjective, what are your thoughts on this? Is there a better way to do this?
What separates the address categories? You mention that you may have a billing address and a site address.
If for example the categories are determined by an attribute called 'category', then all you have to do is set a condition on the association declarations on the addressable:
class Account < ApplicationRecord
has_one :billing_address, -> { where category: 'billing' }, class_name: "Address", as: :addressable
has_one :site_address, -> { where category: 'site' }, class_name: "Address", as: :addressable
end

ActiveRecord polymorphic association with unique constraint

I have a site that allows users to log in via multiple services (LinkedIn, Email, Twitter, etc..).
I have the below structure set up to model a User and their multiple identities. Basically a user can have multiple identieis, but only one of a given type (e.g. can't have 2 Twitter identiteis).
I decided to set it up as a polymorphic relationship, as drawn below. Basically there's a middle table identities that maps a User entry to multiple *_identity tables.
The associations are as follows (shown only for LinkedInIdentity, but can be extrapolated)
# /app/models/user.rb
class User < ActiveRecord::Base
has_many :identities
has_one :linkedin_identity, through: :identity, source: :identity, source_type: "LinkedinIdentity"
...
end
# /app/models/identity
class Identity < ActiveRecord::Base
belongs_to :user
belongs_to :identity, polymorphic: true
...
end
# /app/models/linkedin_identity.rb
class LinkedinIdentity < ActiveRecord::Base
has_one :identity, as: :identity
has_one :user, through: :identity
...
end
The problem I'm running into is with the User model. Since it can have multiple identities, I use has_many :identities. However, for a given identity type (e.g. LinkedIn), I used has_one :linkedin_identity ....
The problem is that the has_one statement is through: :identity, and there's no singular association called :identity. There's only a plural :identities
> User.first.linkedin_identity
ActiveRecord::HasManyThroughAssociationNotFoundError: Could not find the association :identity in model User
Any way around this?
I would do it like so - i've changed the relationship name between Identity and the others to external_identity, since saying identity.identity is just confusing, especially when you don't get an Identity record back. I'd also put a uniqueness validation on Identity, which will prevent the creation of a second identity of the same type for any user.
class User < ActiveRecord::Base
has_many :identities
has_one :linkedin_identity, through: :identity, source: :identity, source_type: "LinkedinIdentity"
end
# /app/models/identity
class Identity < ActiveRecord::Base
#fields: user_id, external_identity_id
belongs_to :user
belongs_to :external_identity, polymorphic: true
validates_uniqueness_of :external_identity_type, :scope => :user_id
...
end
# /app/models/linkedin_identity.rb
class LinkedinIdentity < ActiveRecord::Base
# Force the table name to be singular
self.table_name = "linkedin_identity"
has_one :identity
has_one :user, through: :identity
...
end
EDIT - rather than make the association for linkedin_identity, you could always just have a getter and setter method.
#User
def linkedin_identity
(identity = self.identities.where(external_identity_type: "LinkedinIdentity").includes(:external_identity)) && identity.external_identity
end
def linkedin_identity_id
(li = self.linkedin_identity) && li.id
end
def linkedin_identity=(linkedin_identity)
self.identities.build(external_identity: linkedin_identity)
end
def linkedin_identity_id=(li_id)
self.identities.build(external_identity_id: li_id)
end
EDIT2 - refactored the above to be more form-friendly: you can use the linkedin_identity_id= method as a "virtual attribute", eg if you have a form field like "user[linkedin_identity_id]", with the id of a LinkedinIdentity, you can then do #user.update_attributes(params[:user]) in the controller in the usual way.
Here is an idea that has worked wonderfully over here for such as case. (My case is a tad diffferent since all identites are in the same table, subclasses of the same base type).
class EmailIdentity < ActiveRecord::Base
def self.unique_for_user
false
end
def self.to_relation
'emails'
end
end
class LinkedinIdentity < ActiveRecord::Base
def self.unique_for_user
true
end
def self.to_relation
'linkedin'
end
end
class User < ActiveRecord::Base
has_many :identities do
[LinkedinIdentity EmailIdentity].each do |klass|
define_method klass.to_relation do
res = proxy_association.select{ |identity| identity.is_a? klass }
res = res.first if klass.unique_for_user
res
end
end
end
end
You can then
#user.identities.emails
#user.identities.linkedin

How to delete nested objects in Rails3?

How can I delete nested objects in a form? I found out that I need to add :allow_destroy in the parent model at the accepts_nested_attributes_for directive.
Further, I want to restrict the deletion. A nested object only should be deleted, if the parent object is the only one that retains the association.
Example:
class Internship < ActiveRecord::Base
belongs_to :company
accepts_nested_attributes_for :company, allow_destroy => true
end
class Company < ActiveRecord::Base
has_many :internships
end
Explanation: A company can host many internships. Therefore, I do not want to delete the company record as long as there is at least one other internship associated with it.
You could use dependent => :destroy
class Internship < ActiveRecord::Base
belongs_to :company
accepts_nested_attributes_for :company, allow_destroy => true
end
class Company < ActiveRecord::Base
has_many :internships, :dependent => :destroy
end
If you return false in a before_destroy filter, then the destroy action will be blocked. So we can check to see if there are any internships associated to the company, and block it if so. This is done in the company model.
class Company < ActiveRecord::Base
has_many :internships
before_destroy :ensure_no_internships
private
def ensure_no_internships
return false if self.internships.count > 0
end
end

Resources