DEV Community

Cover image for Handling X -> Y -> X Relationships in Rails
Michael Chaney
Michael Chaney

Posted on

Handling X -> Y -> X Relationships in Rails

I'm creating another publishing database, and this time I have to get the relationships between publishers correct. Up until now, I've been able to cheat a bit as the publishers that I work with tend to be in the film and TV music business and have pretty simple publishing needs. But it's time to really model this properly.

Publishers can make deals with other publishers. There are two deals that we're specifically interested in:

  1. Subpublishing
  2. Administration

There's no need to get too far into the specifics of these deals, but in general one publisher is the "assignor" and the other is the "assignee" (or "acquiror"). The agreement itself has information as well: minimally an agreement number (assigned by a performing rights organization), some sort of royalty share percentage, and a territory(s) covered by this agreement.

So, the basic form of this is Publisher -> SubpublishingAgreement -> Publisher, or Publisher -> AdministrationAgreement -> Publisher. Data-wise, these agreements are practically the same so I'll simply talk about SubpublishingAgreement.

In the world of Ruby on Rails, we might use models like these:

class Publisher < ApplicationRecord
  has_many :subpublishing_agreements_as_assignor,
    class_name: "SubpublishingAgreement",
    foreign_key: :assignor_id,
    inverse_of: :assignor,
    dependent: :destroy
  has_many :subpublishing_agreements_as_assignee,
    class_name: "SubpublishingAgreement",
    foreign_key: :assignee_id,
    inverse_of: :assignee,
    dependent: :destroy
end

class SubpublishingAgreement < ApplicationRecord
  belongs_to :assignor,
    class_name: 'Publisher',
    foreign_key: :assignor_id,
    inverse_of: :subpublishing_agreements_as_assignor
  belongs_to :assignee,
    class_name: 'Publisher',
    foreign_key: :assignee_id,
    inverse_of: :subpublishing_agreements_as_assignee
end
Enter fullscreen mode Exit fullscreen mode

There are a couple of important aspects of this to keep in mind as we program it:

  1. This isn't a general graph where there might be loops. Every relationship should be part of a tree with the base being the original publisher.
  2. There can be many such relationships. It's quite possible for a publisher to have an administrator or set of administrators as well as a few subpublishers, and the subpublishers themselves may have administrators. Each administrator and subpublisher would have a territory or set of territories that they handle.

This picture taken from the Common Works Registration functional specifications gives an idea of how this works in practice:

Publishing Hierarchy

This can be modeled pretty easily as shown above. If I want to know what subpublishers a publisher has, for instance, I can simply pull them from the SubpublishingAgreement records or use a "has_many...through" relationship.

From a RESTful standpoint, we have a problem. Let's say I'm looking at a publisher record and want to know what subpublishing agreements the publisher is a party to. The issue is that the publisher may be the assignor or the assignee.

Normally, I'd build a RESTful resource path like this:

/publishers/1/subpublishing_agreements
Enter fullscreen mode Exit fullscreen mode

But that path doesn't really capture the complete picture. Are we looking for subpublishing agreements where Publisher 1 is the assignor, assignee, or perhaps either?

There are a couple of options. The first is to add the role into the path part for agreements:

/publishers/1/subpublishing_agreements_as_assignor
/publishers/2/subpublishing_agreements_as_assignee
Enter fullscreen mode Exit fullscreen mode

That's readable and makes sense, but it's a bear to add to Rails routing.

How about this?

/assignors/1/subpublishing_agreements
/assignees/2/subpublishing_agreements
Enter fullscreen mode Exit fullscreen mode

This puts the role into the path up one level. But, it's also a bear to add to Rails routing.

Let's talk about that first one. It's nice that the nomenclature matches that from the model. But, this is a bit ugly in terms of routing. My goal is to have a basic resource-based route. But here's the issue: the later part of the path dictates what the earlier part references. Let me explain that. In this route:

/publishers/1/subpublishing_agreements_as_assignor
Enter fullscreen mode Exit fullscreen mode

I can see that Publisher 1 is the assignor.

/publishers/2/subpublishing_agreements_as_assignee
Enter fullscreen mode Exit fullscreen mode

And in this route, Publisher 2 is the assignee. But I can't know that from the first part of the route.

Ugh.

Let's go down this path and see where it leads.

First, how do we make routes for this? I want for my "publisher_id" to be called "assignor_id" or "assignee_id" in the params. I'm not proud of this, but it works:

    # Nested routes under publishers for role-specific agreements
    scope "/publishers" do
      # Agreements where the publisher is the assignor
      scope ":assignor_id", as: :publisher do
        resources :subpublishing_agreements,
                  controller: "subpublishing_agreements",
                  as: :subpublishing_agreements_as_assignor,
                  path: :subpublishing_agreements_as_assignor
      end

      # Agreements where the publisher is the assignee
      scope ":assignee_id", as: :publisher do
        resources :subpublishing_agreements,
                  controller: "subpublishing_agreements",
                  as: :subpublishing_agreements_as_assignee,
                  path: :subpublishing_agreements_as_assignee
      end
    end
Enter fullscreen mode Exit fullscreen mode

It's difficult to really tell what this is doing, but it creates the routes shown above.

There is one major problem, though. Let's say I want to put this inside another resource. I do, because this is a multi-tenanted application and the full path will be something like this:

/libraries/1/publishers/1/subpublishing_agreements_as_assignor/1
Enter fullscreen mode Exit fullscreen mode

(note that I personally have no problem with long paths like this that give details of the entire relationship)

Normally, we would have a path like this:

/libraries/1/publishers/1/subpublishing_agreements/1
Enter fullscreen mode Exit fullscreen mode

Again, the issue here is that "subpublishing_agreements" is ambiguous. But let's consider it for a minute. If I go this route, then in the controller I need to handle the fact that the publisher might be the assignor or the assignee:

def get_publisher
  @publisher = Publisher.find(params.expect(:publisher_id))
end

def index
  @subpublishing_agreements = @publisher.subpublishing_agreements_as_assignor + @publisher.subpublishing_agreements_as_assignee
end
Enter fullscreen mode Exit fullscreen mode

Well, that's one way to do it, but note that @subpublishing_agreements is an array in this case, not an ActiveRecord relation. There's no way to build on it, sort it, whatever you might want to do in the database. It's possible to do whatever you want in Ruby, but if you try to add .order(:starts_on) or something like that it'll fail.

You can make it into a regular relationship like this:

  @subpublishing_agreements = SubpublishingAgreement.where("? in (assignor_id, assignee_id)", @publisher.id)
Enter fullscreen mode Exit fullscreen mode

That's possible, but really not normal.

Related to that:

  @subpublishing_agreements = SubpublishingAgreement.where(assignor_id: @publisher.id).or(SubpublishingAgreement.where(assignee_id: @publisher.id))
Enter fullscreen mode Exit fullscreen mode

Technically, you can turn that into a single "where" and put your "or" inside the SQL snippet:

  @subpublishing_agreements = SubpublishingAgreement.where("assignor_id = :publisher_id or assignee_id = :publisher_id", publisher_id: @publisher.id)
Enter fullscreen mode Exit fullscreen mode

Now, this works, kind of. When I create multi-tenanted applications, I always restrict my queries back to the main organization model - library in this case. I can still do that, just not in the way that I'd like. One great thing about this is that I have a relation that I can build on:

  @subpublishing_agreement = @subpublishing_agreements.find(params.expect(:subpublishing_agreement_id))
Enter fullscreen mode Exit fullscreen mode

The other issue arises in displaying this data. Normally, I'd show this list of subpublishing agreements in a simple table, but there would be no need to put the parent datum in this table since it's the same for all and can be shown above. But in this scenario the parent datum may be in one of two different places. In that case, I can either create two tables - agreements as assignor and agreements as assignee, sort them based on this type, or at least put both data fields on each row.

To get back to our way of handling it, we change the routes to show which role the "publisher" in the path is taking on. This allows us to use some of the Rails tools at our disposal for creating routes, but it also breaks a lot of it.

A big problem that I face is that I want to be able to have the library at the front of the path. Each account may have multiple libraries, so that's important. But with the scopes in the routes as above, putting that code within a resources :libraries do...end block ends up with paths that we don't want:

publisher_library_subpublishing_agreements_as_assignee
/publishers/:assignee_id/libraries/:library_id/subpublishing_agreements_as_assignee/:id(.:format)
Enter fullscreen mode Exit fullscreen mode

That's right - the library ends up after the scoping. Argh.

But, let's explore this, anyway.

When I create controllers like this, I like to use a helper that I wrote called filled_path. It allows me to pretty easily reuse a lot of code across different controller end points and at different paths.

For instance, just looking at this particular sort of data model as an administrator I might want to view all of the publishers or just the publishers for a particular library. It's also the case that an ordinary user may access their libraries in a similar manner, but in a path prefixed with "/my".

/admin/publishers/1
/admin/libraries/1/publishers/1
/my/libraries/1/publishers/1
Enter fullscreen mode Exit fullscreen mode

In this case, there's no reason for a regular user to access a publisher outside of their library-specific path. But, for an administrator, that might be optional.

  namespace :my do
    resources :libraries do
      resources :publishers
    end
  end

  namespace :admin do
    resources :publishers
    resources :libraries do
      resources :publishers
    end
  end
Enter fullscreen mode Exit fullscreen mode

This allows an administrator to access publishers either directly or under the libraries path. This works out well because Rails path helpers accept an array with the path items. For instance:

[:admin, @library, @publisher]
[:my, @library, @publisher]
Enter fullscreen mode Exit fullscreen mode

The only difference in code is the first piece. There are a few tricks to know here. If you want to get the "edit" path, for instance, then the action must be prepended to the array:

[:edit, :my, @library, @publisher]
Enter fullscreen mode Exit fullscreen mode

And if you want to see the subpublishing_agreements for a publisher, you would append the relevant path item:

[:my, @library, @publisher, :subpublishing_agreements]
Enter fullscreen mode Exit fullscreen mode

My filled_path helper constructs those arrays, and you can use such an array for any path in Rails, including in links or in form actions. I'll talk a little more about filled_path later.

  form_for([:my, @library, @publisher]) do
    ...
  end
Enter fullscreen mode Exit fullscreen mode

That works, until you break it with scopes.

The fundamental issue is that scopes will allow you to construct a path, but instead of an object for subpublishing_agreement you have to use the plain id. Let me show you a bit of the controller. Again, this is for administrative access so tying this to a particular library doesn't matter:

class Admin::SubpublishingAgreementsController < Admin::ApplicationController
  before_action :set_library
  before_action :set_assignor_or_assignee
  before_action :figure_out_path_filling
  before_action :set_collection
  before_action :set_subpublishing_agreement, only: %i[ show edit update destroy ]

...

  private
    # This sets @publisher as well as either @assignor or @assignee
    def set_assignor_or_assignee
      @publisher =
        if params[:assignor_id].present?
          @assignor = Publisher.find(params[:assignor_id])
        elsif params[:assignee_id].present?
          @assignee = Publisher.find(params[:assignee_id])
        end
    end

    def set_collection
      @collection =
        if @assignor.present?
          @assignor.subpublishing_agreements_as_assignor
        elsif @assignee.present?
          @assignee.subpublishing_agreements_as_assignee
        else
          SubpublishingAgreement
        end
    end

    def set_subpublishing_agreement
      @subpublishing_agreement = @collection.find(params.expect(:id))
    end

    def figure_out_path_filling
      if params[:assignor_id].present?
        @subpub_agreement_path_piece = :subpublishing_agreements_as_assignor
      elsif params[:assignee_id].present?
        @subpub_agreement_path_piece = :subpublishing_agreements_as_assignee
      else
        @subpub_agreement_path_piece = :subpublishing_agreements
      end
    end

    def sub_pub_path_helper(action, subpub_agreement=nil)
      if @publisher.present?
        general_path = [:admin, @publisher, @subpub_agreement_path_piece]
        case action.to_sym
        when :new
          polymorphic_path([:new] + general_path)
        when :edit
          polymorphic_path([:edit] + general_path, id: subpub_agreement.id)
        when :show, :update, :destroy
          polymorphic_path(general_path, id: subpub_agreement.id)
        when :index, :create
          polymorphic_path(general_path + [:index])
        else
          raise "Unknown action #{action}"
        end
      end
    end
end
Enter fullscreen mode Exit fullscreen mode

What a mess. The real action here is in sub_pub_path_helper. Imagine that I have this path:

/admin/publishers/1/subpublishing_agreements_as_assignor/2
Enter fullscreen mode Exit fullscreen mode

In this case, the params will have the assignor_id key. This means that @publisher and @assignor will be set to Publisher 1 and the SubpublishingAgreement will be 2. @subpub_agreement_path_piece will be set to :subpublishing_agreements_as_assignor.

Now, in sub_pub_path_helper the general_path will be:

[:admin, @publisher, subpublishing_agreements_as_assignor]
Enter fullscreen mode Exit fullscreen mode

If you wish to create the "edit" path for this item, the helper will prepend :edit to the array, and also pass id: 2 to polymorphic_path. In the end, you'll get:

/admin/publishers/1/subpublishing_agreements_as_assignor/2/edit
Enter fullscreen mode Exit fullscreen mode

That's a lot of work to basically add "/edit" to the path. The form looks like this:

form_with(model: subpublishing_agreement, url: sub_pub_path_helper(subpublishing_agreement.new_record? ? :create : :update, subpublishing_agreement)) do |form|
Enter fullscreen mode Exit fullscreen mode

Yes, we have to decide which URL to provide based on the record status. That sub_pub_path_helper ends up everywhere. Simply put, every URL has to use that helper or include all of that logic. It's a bit of a mess. As stated earlier, it's also difficult if I want to include another resource (e.g. "library") at the front of the path.

Let's try something else.

Here's a piece of a route file to attempt another approach:

  namespace :admin do
    resources :libraries do
      resources :publishers

      resources :publishers, path: 'assignors', as: :assignors, only: [] do
        resources :subpublishing_agreements
      end

      resources :publishers, path: 'assignees', as: :assignees, only: [] do
        resources :subpublishing_agreements
      end
    end
  end
Enter fullscreen mode Exit fullscreen mode

This has potential. Just showing the paths for the generic show method:

     admin_library_assignor_subpublishing_agreement GET    /admin/libraries/:library_id/assignors/:assignor_id/subpublishing_agreements/:id(.:format)        admin/subpublishing_agreements#show
     admin_library_assignee_subpublishing_agreement GET    /admin/libraries/:library_id/assignees/:assignee_id/subpublishing_agreements/:id(.:format)        admin/subpublishing_agreements#show
              admin_library_subpublishing_agreement GET    /admin/libraries/:library_id/subpublishing_agreements/:id(.:format)                               admin/subpublishing_agreements#show

Enter fullscreen mode Exit fullscreen mode

There's some potential here. This allows us to make a path like this:

/admin/libraries/1/assignors/5/subpublishing_agreements/2
Enter fullscreen mode Exit fullscreen mode

This would be Library 1, Publisher 5, and SubpublishingAgreement 2 with the assumption that Publisher 5 would be the assignor for that particular agreement.

But, how do we specify such a path using helpers?

It turns out that polymorphic_path can handle these with a little care. Take the above as an example:

  polymorphic_path([:admin, Library.find(1), :assignor, SubpublishingAgreement.find(2)], assignor_id: 5)
Enter fullscreen mode Exit fullscreen mode

That's not optimal, but it's pretty easy to get it to work. This is my filled_path helper:

module FilledPathHelper
  def set_path_filling(*params)
    @path_filling = params.compact
  end

  def set_path_options(options = {})
    @path_options = options.compact
  end

  def final_filled_path(pre, post, **options)
    path = Array(pre || []) + (@path_filling || []) + Array(post || [])
    merged_options = (@path_options || {}).merge(options)
    merged_options.empty? ? path : polymorphic_path(path, merged_options)
  end

  def filled_path(*params, **options)
    normalize = ->(arg) {
      case arg
      when String then arg.to_sym
      when Array  then arg.map { |s| normalize.call(s) }
      else arg
      end
    }

    params = normalize.call(params)

    case params
    in [ Array ]
      final_filled_path(nil, params.flatten, **options)
    in [ ApplicationRecord, * ]
      final_filled_path(nil, params, **options)
    in [ Symbol => post ]
      final_filled_path(nil, post, **options)
    in [ Symbol => pre, *post ]
      final_filled_path(pre, post.flatten, **options)
    in []
      final_filled_path(nil, nil, **options)
    else
      raise ArgumentError, "Invalid arguments to filled_path"
    end
  end

  def filled_url(*params, **options)
    polymorphic_url(filled_path(*params), **options)
  end
end
Enter fullscreen mode Exit fullscreen mode

Normally, you use it as such:

class Admin::BooksController < Admin::ApplicationController
  before_action :set_library
  before_action -> { set_path_filling :admin, @library }
  before_action :set_collection
  before_action :set_book, only: [ :show, :edit, :update, :destroy ]

  ....

  private

  def set_library
   @library = Library.find(params[:library_id])
  end

  def set_collection
    @collection = @library.books
  end

  def set_book
    @book = @collection.find(params[:id])
  end
end
Enter fullscreen mode Exit fullscreen mode

This is assuming that a Book belongs to a Library. A typical URL might be:

/admin/libraries/2/books/5
Enter fullscreen mode Exit fullscreen mode

To link to a particular book:

link_to @book.title, filled_path(@book)
Enter fullscreen mode Exit fullscreen mode

Is the equivalent of:

link_to @book.title, [:admin, @library, @book]
# or
link_to @book, admin_library_book_path(@library, @book)
Enter fullscreen mode Exit fullscreen mode

The nice thing about filled_path is that you can use your mostly same code in a different path by simply changing the initial path filling.

But it requires a little more fun when using it with these paths, as we have to get the assignor_id or assignee_id into the path. And to do that we have to also set some path options.

class Admin::SubpublishingAgreementsController < Admin::ApplicationController
  before_action :set_library
  before_action :set_publisher_context
  before_action :set_subpublishing_agreement, only: [:show, :edit, :update, :destroy]

  ....

  private

    def set_library
      @library = Library.find(params[:library_id])
    end

    def set_publisher_context
      if params[:assignor_id]
        @assignor = @library.publishers.find(params[:assignor_id])
        set_path_filling(:admin, @library, :assignor)
        set_path_options(assignor_id: @assignor.id)
      elsif params[:assignee_id]
        @assignee = @library.publishers.find(params[:assignee_id])
        set_path_filling(:admin, @library, :assignee)
        set_path_options(assignee_id: @assignee.id)
      end
    end

    def set_subpublishing_agreement
      @subpublishing_agreement =
        if @assignor
          @assignor.subpublishing_agreements_as_assignor.find(params[:id])
        else
          @assignee.subpublishing_agreements_as_assignee.find(params[:id])
        end
    end
end
Enter fullscreen mode Exit fullscreen mode

With this in place, I can use filled_path to handle all path generation to automatically include the assignor or assignee id.

This allows me to create paths easily for the subpublishers functionality, and it's pretty easy to dip in from the publishers' "show" page:

  <%= link_to "Subpublishing Agreements as Assignor", polymorphic_path([:admin, @publisher.library, :assignor, :subpublishing_agreements], :assignor_id => @publisher.id) %> |
  <%= link_to "Subpublishing Agreements as Assignee", polymorphic_path([:admin, @publisher.library, :assignee, :subpublishing_agreements], :assignee_id => @publisher.id) %> |
  <%= link_to "Edit", filled_path(:edit, @publisher) %> |
  <%= link_to "Back to publishers", filled_path(:publishers) %>
Enter fullscreen mode Exit fullscreen mode

The reason I go to these lengths is to keep the context. If I ask for the subpublishing agreements where a certain publisher is the assignor, I can see those agreements and see that the publisher in the path is the assignor. If I add an agreement, the assignor is already set, so I just have to choose an assignee on the form.

  <div class="row">
    <div class="col-md-6">
      <div class="mb-3">
        <%= form.label :assignor_id, class: "form-label" %>
        <% if @assignor.present? %>
          <%= form.text_field :assignor_id, value: @assignor.name, class: "form-control", disabled: true %>
        <% else -%>
          <%= form.collection_select :assignor_id, @library.publishers.order(:name), :id, :name, { prompt: true }, { class: "form-select" }  %>
        <% end -%>
      </div>
    </div>

    <div class="col-md-6">
      <div class="mb-3">
        <%= form.label :assignee_id, class: "form-label" %>
        <% if @assignee.present? %>
          <%= form.text_field :assignee_id, value: @assignee.name, class: "form-control", disabled: true %>
        <% else -%>
          <%= form.collection_select :assignee_id, @library.publishers.order(:name), :id, :name, { prompt: true }, { class: "form-select" }  %>
        <% end -%>
      </div>
    </div>
  </div>
Enter fullscreen mode Exit fullscreen mode

This context allows you to put together user workflows that are intuitive:

  1. Choose a publisher
  2. View subpublishing agreements as the assignor
  3. Create a new agreement
  4. Automatically get redirected back to subpublishing agreements as assignor
  5. Return to the publisher's main page

Wrap Up

This particular pattern isn't very common, but knowing how to put it together can definitely make an easy to maintain application that's also easy for your users.

Feel free to ask questions in the comments.

Top comments (0)