Ruby on Rails is a "batteries-included" framework. Out of the box in a 6.0.0.rc1 install, you get the following support libraries included directly with the rails
dependency:
- Active Model, an attribute validation and callback provider
- Active Job, a queueing system for background processing
- Active Record, a unified ORM for Rails with pluggable database adapters
- Active Storage, a manager for uploading files to external hosting services such as B2 and S3
- Action Controller, a Rack-compatible routing and response provider that handles your web requests
- Action Mailer, a simple implementation of a mailer
- Action Mailbox, an automated mail inbox for managing mailed responses to a central application
- Action Text, a formatting tool and editor for user content like text posts
- Action View, a provider for view templates for use in controllers
- Action Cable, a Rack-compatible WebSocket server
- Sprockets, an asset compilation and delivery pipeline for web applications
You are not obligated to use any of these components if you do not want to use them.
Configuration
All of the above components, plus a unit test framework, are automatically required by the 'rails/all' file. However, I would wager that many applications will not have any use for Active Storage, Action Mailbox, Action Text, or Action Cable, and allowing them to be automatically required by the framework can cause them to do things like insert routes into your application that you did not mean.
To exemplify what I mean by this, requiring 'rails/action_mailbox' in your application.rb file will always add these additional routes to your routing table. This is not configurable; you will always get all of these.
POST /rails/action_mailbox/mandrill/inbound_emails(.:format)
POST /rails/action_mailbox/postmark/inbound_emails(.:format)
POST /rails/action_mailbox/relay/inbound_emails(.:format)
POST /rails/action_mailbox/sendgrid/inbound_emails(.:format)
POST /rails/action_mailbox/mailgun/inbound_emails/mime(.:format)
GET /rails/conductor/action_mailbox/inbound_emails(.:format)
POST /rails/conductor/action_mailbox/inbound_emails(.:format)
GET /rails/conductor/action_mailbox/inbound_emails/new(.:format)
GET /rails/conductor/action_mailbox/inbound_emails/:id/edit(.:format)
GET /rails/conductor/action_mailbox/inbound_emails/:id(.:format)
PATCH /rails/conductor/action_mailbox/inbound_emails/:id(.:format)
PUT /rails/conductor/action_mailbox/inbound_emails/:id(.:format)
DELETE /rails/conductor/action_mailbox/inbound_emails/:id(.:format)
POST /rails/conductor/action_mailbox/:inbound_email_id/reroute(.:format)
Requiring 'rails/active_storage' will always add these routes.
GET /rails/active_storage/blobs/:signed_id/*filename(.:format)
GET /rails/active_storage/representations/:signed_blob_id/:variation_key/*filename(.:format)
GET /rails/active_storage/disk/:encoded_key/*filename(.:format)
PUT /rails/active_storage/disk/:encoded_token(.:format)
POST /rails/active_storage/direct_uploads(.:format)
This behavior can be particularly surprising to anyone tasked with upgrading an existing application, because the upgrade process does not inject anything into your routes file, nor is there any indication that if you have 'rails/all' included that it should do this without warning. This is not mentioned in the Rails guides for these libraries.
A common pattern to stem invalid path errors in Rails is to add a route such as match '*path', to: 'errors#error_404'
as the last route in the configuration. This is necessary to hide the chaff due to common crawlers (such as those looking for vulnerable Wordpress installations) triggering invalid routes, and to provide a consistent user interface for all pages which were not found, not just routable pages with no associated model. There are some problems with this, which are again not documented, and tend to lead to mysterious error messages cropping up in the error log.
However, if you do this, then both your Action Mailbox and Active Storage routes will be completely inaccessible. This is because the match '*path'
expression is evaluated first in the routing table, and always wins precedence. Nothing after it will ever be routed. Making matters worse, it does not seem to be possible to fix this without fundamentally changing the way the include order works.
To add insult to injury, the automatic injection of routes is actually inconsistent with Rails's own previous behavior. For example, to have an Action Cable server in your application, you have to explicitly mount it in your routes file with mount ActionCable.server => '/some_websocket_location'
. You will not get it configured otherwise, and this implementation remedies any problems that may occur with the include order of your routing table. But for some inexplicable reason, Rails does not do this for its new libraries Active Storage and Action Mailbox.
Paths
Rails encourages you not to use the lib
folder. This is stupid. You should use the lib
folder to your heart's desire. Just make sure you actually put classes in there that have at least a semblance of decoupling from your application.
Routing
Routing, when used the way it is intended, is one of Rails's best features. Unfortunately, Rails does not do a very good job of explaining to users the sheer usefulness of resourceful routing, which is why it is a difficult praise for most to sing.
For example, booru-on-rails has this bit of crust in it, added by a developer unfamiliar with how resourceful routing works:
get '/commissions' => 'commissions#index', as: :commissions
get '/commissions/:user' => 'commissions#show', as: :show_commissions
get '/commissions/:user/edit' => 'commissions#edit', as: :edit_commissions
put '/commissions/:user/save' => 'commissions#save', as: :save_commissions
delete '/commissions/:user/destroy' => 'commissions#destroy', as: :destroy_commissions
get '/commissions/:user/add_item' => 'commissions#add_item', as: :add_commissions_item
put '/commissions/:user/save_item' => 'commissions#save_item', as: :save_commissions_item
get '/commissions/:user/edit_item/:id' => 'commissions#edit_item', as: :edit_commissions_item
put '/commissions/:user/update_item/:id' => 'commissions#update_item', as: :update_commissions_item
delete '/commissions/:user/delete_item/:id' => 'commissions#delete_item', as: :delete_commissions_item
Now, here is how you would write the same code with resourceful routing (note that you would need two controllers instead of one):
resources :commissions do
resources :commission_items, except: [:index]
end
No kidding. This is, along with automatic controller and template generation, a killer feature, and it's one of the things that made Rails so popular from the very beginning. The generator can do 90% of the work for you with minimal effort, all the way down to generating <select>
tags for you in the template. For a simple CRUD model, you only need a little extra to get the code the rest of the way there.
Besides the relative lack of emphasis on resourceful routing, I do still have a few complaints left over:
- Should you choose not to use resourceful routing for a particular set of routes, it makes it nearly impossible to use the
form_for
view helper. - Singular resources, for example something like a nested
resource :history
, will be routed with a singular path name, but to a plural controller. - The route path helpers aren't all that easy to memorize, again particularly in the case of singular vs plural names on singular routes.
Active Record
ORMs suck. There is a lot of well-established precedent for this and it's been pointed out many, many times as a key complaint about many object-relational mapping systems. What I find to be the most infuriating aspect of this "debate" is that the problem with ORMs being so sucky isn't the fault of the database at all! It's a problem entirely inherent to the fact that object-oriented programming is, at best, fundamentally incompatible with data-oriented protocols. It's also entirely incompatible with the way computers want to operate on data at a basic level, though it can be nice sometimes to program in.
But that's not what I'm here to fault Active Record for. There is a lot of Active Record-specific suckage that has nothing to do with the "object-relational impedance mismatch". To give you a more specific example about the kind of attitude I'm talking about, let's unpack a statement made from Rails's own guide to Active Record:
Model-level validations are the best way to ensure that only valid data is saved into your database. They are database agnostic, cannot be bypassed by end users, and are convenient to test and maintain.
...
Database constraints and/or stored procedures make the validation mechanisms database-dependent and can make testing and maintenance more difficult. [...] Additionally, database-level validations can safely handle some things (such as uniqueness in heavily-used tables) that can be difficult to implement otherwise.
What the hell?
Regardless of how database-specific they may be, database constraints are insurance. They are a way of telling the database that, no matter what happens in your app code, that there are conditions which must never happen. Adding constraints to a database is important to restrict these cases in a way that makes coding errors more obvious and easier to deal with.
For example, consider what would happens in booru-on-rails if, for example, there was a coding error that did not update references when a filter gets deleted. Now say that a user was active on the filter at the time it got deleted. Without a foreign key constraint, the user would have a dangling reference to a nonexistent filter. Chances are now that this dangling reference will never be corrected.
With the foreign key constraint, the database catches your error and guarantees that the outcome will always be consistent, no matter what you do in your code. This is in stark contrast to how on the application side, a mistaken refactor could accidentally entirely wipe out your dependent column handling, and leave you with inconsistency you don't realize is there until long after, and spurious errors start coming up. Yeah.
To its credit, Active Record does allow you to create three major types of constraints:
- Foreign key constraints, with dependent options
- Not null constraints
- Unique indices, including multi-column unique indices
Two of these aren't even truly constraints. They are instead attributes of particular columns (not null constraints) or indices (a unique index creates an implicit unique constraint). There are also two other standard SQL constraints here that are conspicuously missing:
- Primary key constraints
- Check constraints
If you want to use these, you will have to handwrite the SQL to do it and change to SQL schema dumps. Yes, you heard me right. It is close to 15 years after the first release of Rails, and you still cannot add a check constraint or use natural keys on your data without going outside the bounds of the framework.
There are yet other useful features of popular databases have that Active Record by design has no support for:
And many more...
But that might be wishful thinking, if ActiveRecord can't even get the basics right. Which it does not.
Up until Rails 6 there was no built in support for the following operations:
- Non-model inserts
- Bulk inserts
- Upserts
- Bulk upserts
Bulk inserts should prove to be a useful addition to Rails... but why did it take until its sixth major release to get this!? And as if that weren't enough of a slap in the face to anyone who can't upgrade yet, the ability to upsert is uselessly and unfairly hampered by the complete inability to perform custom updates. So if you have Ruby code that looks like this (which I would guess is pretty common)...
(['insert into user_uses (user_id, uses) values (?, 0) on conflict (user_id) do update set uses = user_uses.uses + 1, updated_at = now()', user.id])
... you still can't refactor it. Sucks, huh?
Certain association methods are completely and wildly unintuitive. Say you have a model setup like this:
class TagChange < ApplicationRecord
belongs_to :image
end
class Image < ApplicationRecord
has_many :tag_changes
end
What do you think the following method call will do, given that @image is a valid Image model?
@image.tag_changes.delete_all
Will it delete all of the tag changes for the image?
No. It will update all of the tag changes for the image to set the image_id to null. This is absurd and violates the principle of least astonishment so badly that I am shocked anyone ever thought it would be good idea.
You can make Active Record do "the right thing" in this case by adding the option dependent: :delete_all
to the has_many
on Image. In principle this solves the issue; in practice people forget, or might eventually change things and remove such a dependent attribute. Which reinforces the point about why you need to use database constraints at a low level.
There are other skeletons in Active Record associations' closet of despair, and one such skeleton goes by the name of "eager loading."
If you have ever taken a course in database design and principles, you might know that statements such as SELECT * FROM ...
are rarely ideal, because they force the database to avoid using any index-only scans and require fetching tuples from the heap. You may also know that a common problem with ORMs is "N + 1 queries", where a query on one object will then trigger the application to make N queries to an association.
Active Record gives you two ways to avoid this: preloading and eager loading. Both are equally valid, but are suited to different circumstances. Unfortunately Active Record manages to mess up both of them.
When you are eager loading, this is generally when you want to do an inner or left outer join to fetch exactly one other result, and you know you can get away with doing it in one query. Active Record messes this up by not allowing you to specify which columns you want to eager load; it will always eager load ALL of them, and there is no way to change that.
When preloading, you probably want to avoid a very large left outer join needlessly returning too many rows. Active Record messes this one up too by not ensuring that it is impossible to make an N+1 query. Generally, as an ORM, you might do this by raising an exception because the association wasn't loaded. Not Active Record! Active Record will be "nice" and load it for you. Whenever you want to enumerate it. No problem. Tools like New Relic could be made almost entirely obsolete if the Rails team had decided early on that trying to enumerate an unloaded association should raise an exception.
Another skeleton is validation handling. Let's assume I play ball with Active Record's idea that validations should be exclusive to the model, and the database should not do any constraint checking. So, I go along and add some errors to the model...
@image.errors.add(:image, 'size must be between 0 and 25 megabytes')
...and then perhaps I would like to check if it's valid...
@image.valid?
#=> true
Wait, what!? But no, it turns out that this behavior is as intended and entirely undocumented. In order to add validations to a model, you have to perform the validations during the validation context. If you can't, you're screwed, and will need a workaround involving putting the errors into an instance variable, adding them at validation time.
In the realm of "easy to implement missing features" we have conditional counter caches. As in, they only update when a certain condition about the model is true. I last counted at least 5 different gems that monkey-patch Active Record to do this. Surely getting it upstreamed wouldn't hurt?
Here's a fun security-related "feature". Suppose you have route with some user-controlled parameters, and you want to pass those into #where
or #find_by
to do a search on them. Say something like this:
user = User.find_by!(authentication_token: params[:key])
Oops. I just used your own, perfectly innocuous-looking code to do something you absolutely didn't mean. How did I do it?
GET /images/0.json?key[]=1&key[]=2&key[]=3&key[]=&...
When passed an array as a where clause value, ActiveRecord will helpfully convert what would normally be a column equality test (=
) into an IN
statement. Cool, isn't it?
While there's a lot that pisses me off about Active Record, I will say that it will definitely work if you put enough care into it. And especially when compared to its competition (like ROM, implementing Data Mapper), it is at least very actively maintained and well understood.
All in all, while it'll probably do the job just fine when it's used in a well-normalized, structurally sound database, but stray from the beaten path even a little bit and it's nightmares all around. To me, it's a wonder that it's survived in its current state for so long.