0

Problem

Okay, so I have a weird structure issue, and the rails conventions I know are not helping.

In my system, Users have Memberships to Servers. This is generically a HABTM association, but I am using has_many through the Memberships model to simplify some things (according to advice from http://blog.flatironschool.com/why-you-dont-need-has-and-belongs-to-many/). Memberships have metadata about the user's relationship with the server. This works pretty well, for what I am doing.

Now, the trouble I run into is that each Membership can have many Profiles for the User to use on the Server. Since Profiles are associated to one User and one Server, I just have the Profiles belong_to Membership. What I want to do is use a counter_cache for the Profiles on the Server model since Rails seems to be running this query constantly:

SELECT COUNT(*) FROM "profiles" INNER JOIN "memberships" ON "profiles"."membership_id" = "memberships"."id" WHERE "memberships"."server_id" = $1

My Attempts

Firstly, Rails does not have a belongs_to through, so profiles cannot "belong_to" servers through the membership model, thus I cannot provide the counter_cache there.

My second thought was to use a delegate on the Server model to delegate :profiles_count to Membership and just do the counter_cache via memberships, but I haven't been able to get this to work.

My third attempt was to delegate :server_id to :membership in Profile and see if that would allow me to build a belongs_to association with the Server model. No good. Funny thing here, there are no errors thrown for this, it just rolls back a save on a new profile without warning.

The only other thing I can think is to add server_id and user_id columns to the Profiles model effectively duplicating the HABTM association from Memberships. Im not sure how stable this level of interconnectedness is going to be. This is effectively going to create has_many associations in two directions, with circular references chains: membership->server->profile->membership....

Question

Is there a specific convention I have missed that handles counter_caching in this fashion, or am I stuck with a choice between:

  1. the status quo of constantly querying the count from the database through an inner join
  2. risking an infinite loop of associations

Supporting Code

Here are the pertinent classes trimmed down to the relevant bits:

class User < ApplicationRecord
  has_many :memberships, dependent: :destroy
  has_many :servers, through: :memberships
  has_many :profiles, through: :memberships
end

class Server < ApplicationRecord
  has_many :memberships
  has_many :users, through: :memberships
  has_many :profiles, through: :memberships

  # This did not work with either singular or plural membership:
  # delegate :profiles_count, to: :membership
end

class Profile < ApplicationRecord
  belongs_to :membership, counter_cache: true

  # The following also does not work:
  # belongs_to :server, counter_cache: true
  # delegate :server_id, to: :membership

  scope :for_user, -> (id) { joins(:membership).where(memberships: { user_id: id }) }
  scope :for_server, -> (id) { joins(:membership).where(memberships: { server_id: id}) }
end

class Membership < ActiveRecord::Base
  belongs_to :user
  belongs_to :server, counter_cache: true
  has_many :profiles, dependent: :destroy
end

Here are the create_tables from db/schema.rb truncated down to just the associated fields for brevity:

  create_table "users", force: :cascade do |t|
    # ...
  end

  create_table "servers", force: :cascade do |t|
    # ...
    t.integer "memberships_count", default: 0
    t.integer "profiles_count", default: 0
  end

  create_table "profiles", force: :cascade do |t|
    t.bigint "membership_id"
    # ...
  end

  create_table "memberships", force: :cascade do |t|
    t.bigint "user_id"
    t.bigint "server_id"
    # ...
    t.integer "profiles_count", default: 0
  end
Omnilord
  • 844
  • 2
  • 16
  • 23
  • Yes, there are foreign keys and other indexes on the appropriate columns in the database. – Omnilord Aug 04 '17 at 07:14
  • 1
    If you dont want to go down the route of having wierd unnecessary relations (which I agree I wouldnt want to either), why not just implement your own custom counter_cache logic for your specific needs? Rails counter cache is a fairly simple class that just increments and decrements the parents count column http://api.rubyonrails.org/classes/ActiveRecord/CounterCache/ClassMethods.html I am sure it wouldnt be too hard to implement a similar thing for your own use case. – John Hayes-Reed Aug 04 '17 at 08:25
  • 1
    From what I can tell, you are out of luck with the counter_cache behavior of ActiveRecord. You can roll your own counter in your model similar to what is described in this [SO question](https://stackoverflow.com/questions/32275640/rails-4-counter-cache-in-has-many-through-association-with-dependent-destroy). I would probably use database triggers to maintain the counter instead of relying on app code. – Wizard of Ogz Aug 04 '17 at 12:15
  • This may also be a premature optimization on my part. I've added a low priority issue to my repo to remind my self to keep an eye out for this becoming a performance issue and to revisit with custom counter logic (per John and Wizard's suggestions) if needed. – Omnilord Aug 06 '17 at 07:06
  • I am also toying with the idea of using this: `@servers = Server.select('servers.*, COUNT(profiles.id) AS profiles_count') .left_joins(:profiles) .group('servers.id')` and accessing the `profiles_count` dynamically added property on each server. It seems to be working well in my dev environment, and I doubt I'm going to have any scaling difficulties since the target dataset size is fairly small. – Omnilord Aug 13 '17 at 09:57

0 Answers0