|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +author: Tony Schneider |
| 4 | +title : A Peek Behind ActiveModel::Model |
| 5 | +date : 2020-06-24 |
| 6 | +tags : software |
| 7 | +--- |
| 8 | + |
| 9 | +I find myself using `ActiveModel::Model` quite a bit. |
| 10 | + |
| 11 | +It’s a quick and easy way to supercharge your ruby objects with all kinds of functionality to make them compatible with `ActionView` (forms) and `ActionPack` (routing). |
| 12 | + |
| 13 | +Aside from this compatibility, it makes your ruby object _feel_ a bit more like an `ActiveRecord` object. |
| 14 | +In fact, much of ActiveModel was extracted from ActiveRecord during the Rails 3 refactors of old. |
| 15 | + |
| 16 | +I often reach for it when making form objects using [The Command Pattern]({% post_url 2020-06-24-the-command-pattern %}). |
| 17 | + |
| 18 | +```ruby |
| 19 | + |
| 20 | +class CancelSubscription |
| 21 | + include ActiveModel::Model |
| 22 | + |
| 23 | + validates :subscription, :presence => true |
| 24 | + validates :cancellation_date, :presence => true |
| 25 | + |
| 26 | + attr_accessor :subscription, :cancellation_date |
| 27 | + |
| 28 | + def execute |
| 29 | + # logic for canceling a subscription |
| 30 | + end |
| 31 | +end |
| 32 | + |
| 33 | +form = CancelSubscription.new( |
| 34 | + subscription: subscription, |
| 35 | + cancellation_date: cancellation_date |
| 36 | +) |
| 37 | +form.valid? # => true / false |
| 38 | +form.execute # => subscription cancelled! |
| 39 | +``` |
| 40 | + |
| 41 | +Let’s take a closer look at `ActiveModel::Model` to see what it’s doing for us behind the scenes. |
| 42 | + |
| 43 | +It implements the following methods: |
| 44 | + |
| 45 | +* `initialize` |
| 46 | +* `persisted?` |
| 47 | + |
| 48 | +It includes these modules: |
| 49 | + |
| 50 | +* `ActiveModel::AttributeAssignment` |
| 51 | +* `ActiveModel::Validations` |
| 52 | +* `ActiveModel::Conversion` |
| 53 | + |
| 54 | +It is extended with these modules: |
| 55 | + |
| 56 | +* `ActiveModel::Naming` |
| 57 | +* `ActiveModel::Translation` |
| 58 | + |
| 59 | +Each of these modules serves a purpose, but can also be used in isolation. |
| 60 | + |
| 61 | +### ActiveModel::AttributeAssignment |
| 62 | + |
| 63 | +This module is included for the sole purpose of making this work: |
| 64 | + |
| 65 | +```ruby |
| 66 | +form = CancelSubscription.new |
| 67 | +form.assign_attributes(subscription: subscription, cancellation_date: cancellation_date) |
| 68 | +form.subscription # => subscription |
| 69 | +form.cancellation_date # => cancellation_date |
| 70 | +``` |
| 71 | + |
| 72 | +The `ActiveModel::Model#initialize` method uses this functionality to implement the `ActiveRecord`-like constructor interface: |
| 73 | + |
| 74 | +```ruby |
| 75 | +form = CancelSubscription.new( |
| 76 | + subscription: subscription, |
| 77 | + cancellation_date: cancellation_date |
| 78 | +) |
| 79 | +``` |
| 80 | + |
| 81 | +It iterates over the hash of parameters passed into the constructor, assigning values using the setters you’re expected to have defined via `attr_accessor`. |
| 82 | + |
| 83 | +If a setter hasn’t been defined, it will raise an `UnknownAttributeError`. |
| 84 | + |
| 85 | +### ActiveModel::Validation |
| 86 | + |
| 87 | +Validations define a rich class-level DSL for expressing what your model considers “valid”. |
| 88 | + |
| 89 | +As a result, instances of the model gain a handful of methods allowing you to inquire about the validity (e.g `model.valid?`). |
| 90 | + |
| 91 | +Once `model.valid?` has been called, it populates `model.errors` with error messages indicating that your model has attribute values that the classes' validators consider _invalid_. |
| 92 | + |
| 93 | +The `model.errors` method returns an instance of `ActiveModel::Error`, which is a hash-like object for accessing attribute errors and messages. |
| 94 | + |
| 95 | +This is extremely useful, especially when constructing models from user-provided data or data from programmer provided configuration. |
| 96 | + |
| 97 | +```ruby |
| 98 | +form = CancelSubscription.new |
| 99 | +form.valid? # => false |
| 100 | +form.errors.full_messages # => ["subscription can't be blank", "cancellation_date can't be blank"] |
| 101 | +``` |
| 102 | + |
| 103 | +`Validations` comes with a whole sweet of built-in validations - some of which you've probably used with `ActiveRecord`. |
| 104 | +It also provides an easy way to define ad hoc validations via `validate` as well as a full-featured `Validator` interface for more re-usable validations. |
| 105 | + |
| 106 | +### ActiveModel::Translation |
| 107 | + |
| 108 | +As you may have guessed, this module is the glue between your model and the Rails i18n internationalization framework. |
| 109 | + |
| 110 | +This module exposes a class level interface for defining translations that correspond to your attributes a la: |
| 111 | + |
| 112 | +```ruby |
| 113 | +CancelSubscription.human_attribute_name(:cancellation_date) |
| 114 | +# => Fecha de cancelación |
| 115 | +``` |
| 116 | + |
| 117 | +Also, it allows you to define an `i18n_scope` method to control the expected i18n key path for your model's translations. |
| 118 | + |
| 119 | +### ActiveModel::Naming |
| 120 | + |
| 121 | +Extending this module gives you a class method called `model_name` that returns an instance of `ActiveModel::Name`. |
| 122 | +Also, instances of your class will delegate the `model_name` to your class. |
| 123 | + |
| 124 | +`ActiveModel::Name` implements much of what powers Rails' "convention over configuration" by taking the name of your class and hooking it up with `ActiveSupport::Inflector`. |
| 125 | + |
| 126 | +Understanding how this module works really pulls the curtain back from the infamous "Rails' Magic". |
| 127 | + |
| 128 | +### ActiveModel::Conversion |
| 129 | + |
| 130 | +Continuing the "Rails Magic" misnomer, `Conversion` is another common source of confusion. |
| 131 | + |
| 132 | +It does the job of extracting data from your model to further inform rails conventions such as: |
| 133 | + |
| 134 | +* Finding partial paths via `to_partial_path` |
| 135 | +* Constructing URLs via `to_param` |
| 136 | + |
| 137 | +Worth noting that by default `to_param` will return `nil` unless your model is `persisted?`. |
| 138 | +However, you can certainly implement it yourself to your liking. |
| 139 | + |
| 140 | +## Putting It All Together |
| 141 | + |
| 142 | +Hopefully, now you see that `ActiveModel::Model` is nothing more than a series of modules meant to make your Ruby objects work more seamlessly with the Rails framework. |
| 143 | + |
| 144 | +Once you realize what they do, it's pretty easy to customize them or omit them entirely to suit your needs. |
| 145 | + |
| 146 | +For example, a few things that bother me about `ActiveModel::Model`: |
| 147 | + |
| 148 | +1. I like to utilize keword args to fail fast. |
| 149 | +1. I dislike having to call super if I need to modify the constructor. |
| 150 | +1. I don't like that calling code has access to setters from `attr_accessor`. |
| 151 | + |
| 152 | +```ruby |
| 153 | +module FormModel |
| 154 | + extend ActiveSupport::Concern |
| 155 | + |
| 156 | + include ActiveModel::Validations |
| 157 | + include ActiveModel::Conversion |
| 158 | + |
| 159 | + included do |
| 160 | + extend ActiveModel::Naming |
| 161 | + extend ActiveModel::Translation |
| 162 | + end |
| 163 | + |
| 164 | + def persisted? |
| 165 | + false |
| 166 | + end |
| 167 | +end |
| 168 | +``` |
| 169 | + |
| 170 | +Now I can do something like this, opting out of the `AttributeAssignment` behavior I don't care for: |
| 171 | + |
| 172 | +```ruby |
| 173 | +class CancelSubscription |
| 174 | + include FormModel |
| 175 | + |
| 176 | + validates :subscription, :presence => true |
| 177 | + validates :cancellation_date, :presence => true |
| 178 | + |
| 179 | + attr_reader :subscription, :cancellation_date |
| 180 | + |
| 181 | + def initialize(subscription:, cancellation_date: Time.zone.today) |
| 182 | + @subscription = subscription |
| 183 | + @cancellation_date = cancellation_date |
| 184 | + end |
| 185 | + |
| 186 | + def execute |
| 187 | + # logic for canceling a subscription |
| 188 | + end |
| 189 | +end |
| 190 | +``` |
0 commit comments