Everything in our knowledge... contains nothing but mere relations —Kant
In this post I will show how a simple idea - tables/models for relationships between other models - makes for happier development.
Core tenets are:
- Always use a tie model if the two models needing an association are not strict parent-child coupling. Order and OrderItem are a strict coupling because no items without parent order exist, whereas some User and Project are not strongly coupled because both can exist without the other.
- Be judicious in separating models from their relationship data. Relationships are as real as physical objects and should be their own models.
- Embrace small tables and models. It may seem like a lot of boilerplate (migration file, model, spec, assoc update in existing models), but clean design will ensure maintainability and performance.
Case1, boyfriends and girlfriends
I swear I saw this in some SO discussion, example may be contrived, but will serve the purpose.
Suppose we are making some social app, have a Boyfriend model and are thinking of introducing a has_one :girlfriend
. This would require a possible Girlfriend model to have the boyfriend_id
column.
# bad
class Boyfriend
has_one :girlfriend
# Table name: boyfriends
#
# id :integer(11) not null, primary key
end
class Girlfriend
belongs_to :boyfriend
# Table name: boyfriends
#
# id :integer(11) not null, primary key
# boyfriend_id :integer(11) not null
end
There are at least two problems with this modelling. Why are boyfriends restricted to only one girlfriend? And why the sexist implication that girlfriends belong to boyfriends?
Adhering to both tenets we see that both boyfriends and girlfriends are in fact their own people, and their relationship is another thing entirely.
# good
class Person
enum gender: {female: 0, male: 1, other: 2}
has_many :relationships, foreign_key: :of_person
_id
# bonus
has_many(
:boyfriends, -> { where(gender: "male") },
through: :relationships, source: :with_person
)
has_many(
:girlfriends, -> { where(gender: "female") },
through: :relationships, source: :with_person
)
# Table name: people
#
# id :integer(11) not null, primary key
# gender :integer(11) not null, default: 0
end
class Relationship
belongs_to :of_person
belongs_to :with_person
# Table name: relationships
#
# id :integer(11) not null, primary key
# of_person_id :integer(11) not null
# with_person_id :integer(11) not null
end
This "directed" or "typed" relationship modelling requires two records, one for the "boyfriend" and the other for the "girlfriend" in the relationship. Can accommodate any gender pairing and with greater typing variety even familial ties.
Case2, primary users of accounts
Inspired by this SO question.
There are accounts with possibly many users (some sort of banking, marketing etc. business arrangement?) and now there's a need to have special, "primary" users, ideally one per account.
# bad
class Account
has_many :users
has_one :primary_user, -> { where(primary: true) }, class_name: "User"
# Table name: accounts
#
# id :integer(11) not null, primary key
end
class User
belongs_to :account
# Table name: users
#
# id :integer(11) not null, primary key
# email :string(200) not null, default ""
# account_id :integer(11) not null
# primary :boolean not null, default false
#
# indexes
# (account_id, primary) UNIQUE, WHERE(primary = true)
end
Again, applying both tenets we see that users probably are not strict "child" objects to accounts, so their relationship to accounts needs to be a separate model. Furthermore, primacy is also a special kind of relationship between the "account-user" relationship and the account.
# good
class Account
has_many :account_users
has_one :primary_user
# Table name: accounts
#
# id :integer(11) not null, primary key
end
class User
has_one :account_user
has_one :account, through: :account_user
# Table name: users
#
# id :integer(11) not null, primary key
# email :string(200) not null, default ""
end
class AccountUser
# Tie model, which account a user is part of.
belongs_to :account
belongs_to :user
# Table name: account_users
#
# id :integer(11) not null, primary key
# account_id :integer(11) not null
# user_id :integer(11) not null
#
# indexes
# (user_id) UNIQUE # a user can only ever be in one account
end
class PrimaryAccountUser
# Stores which user is the primary user in an account.
# Note that belonging to :account_user instead of :user reduces the risk of the relationship being removed, but primacy remaining.
belongs_to :account_user
belongs_to :account
# Table name: account_users
#
# id :integer(11) not null, primary key
# account_id :integer(11) not null
# account_user_id :integer(11) not null
#
# indexes
# (account_id) UNIQUE # an account can only ever have one primary user
end
This modelling allows making Accounts and Users that are not dependent on each other. Users can be associated with an account by making a AccountUser record, and finally, a primary user can be specified by making a PrimaryAccountUser record (provided the user is associated with the account first).
Top comments (0)