Alternatives to classic association fails

Augusts Bautra - Jan 30 '21 - - Dev Community

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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).

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .