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:
- Subpublishing
- 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
There are a couple of important aspects of this to keep in mind as we program it:
- 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.
- 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:
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
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
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
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
I can see that Publisher 1 is the assignor.
/publishers/2/subpublishing_agreements_as_assignee
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
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
(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
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
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)
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))
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)
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))
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)
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
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
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]
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]
And if you want to see the subpublishing_agreements
for a publisher, you would append the relevant path item:
[:my, @library, @publisher, :subpublishing_agreements]
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
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
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
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]
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
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|
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
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
There's some potential here. This allows us to make a path like this:
/admin/libraries/1/assignors/5/subpublishing_agreements/2
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)
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
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
This is assuming that a Book belongs to a Library. A typical URL might be:
/admin/libraries/2/books/5
To link to a particular book:
link_to @book.title, filled_path(@book)
Is the equivalent of:
link_to @book.title, [:admin, @library, @book]
# or
link_to @book, admin_library_book_path(@library, @book)
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
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) %>
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>
This context allows you to put together user workflows that are intuitive:
- Choose a publisher
- View subpublishing agreements as the assignor
- Create a new agreement
- Automatically get redirected back to subpublishing agreements as assignor
- 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)