Mobility 0.3: Ready for Prime Time

Just a little over six months ago, I released the first version of the Ruby translation framework I’ve called Mobility. With the third release of the gem – and a growing list of companies using it to power their production applications – I’m happy to say that Mobility is today more stable and reliable than ever before.

Column Strategy

And we have a liftoff1

As its name implies, Mobility is all about giving you choices. Whether you store your model translations in a set of model-specific tables, in a single shared table, as columns on each model table, or as values in a Postgres jsonb or hstore column, Mobility has you covered. Whether you use fallbacks, dirty change tracking, query support, or any of its other features, Mobility will do what you need it to do without getting in the way.

This week, I’ve released Mobility 0.3, and in the spirit of its name, this release (among other things) will keep you up to speed with your gem dependencies. Version 0.3 includes full support for the latest versions of its ORM dependencies: Rails/ActiveRecord 5.2 (literally just released in beta) as well as Sequel 5.0.

This release also comes with some important changes by a new contributor: Paul McMahon (@pwim), who has been also helping out commenting on and reviewing PRs. Having someone with Paul’s skill and experience helping out has been a huge boost; the fact that Paul is using Mobility in production on his own service, Doorkeeper, has also been great feedback for identifying important issues and fixing them quickly.

Paul and I have put together a short wiki page on migrating from Globalize to Mobility, which is what he did with Doorkeeper; be sure to check it out if you are using Globalize and considering switching to Mobility. For most users, Mobility will be a nearly 100% drop-in replacement for Globalize, and the migration process should be quite simple (as it was for Paul). However, for anyone with questions, I have setup a gitter community for discussions, and I also am watching for StackOverflow questions with the mobility tag.

So, without further ado, here are the changes…

Support for Rails 5.2 and Sequel 5

Yes, Rails 5.2.beta.2 was just released this week, and we’re already running tests against it. After a few small compatibility changes, those specs are now all passing and we are ready for ActiveRecord 5.2!

Sequel 5.0 was also recently released, and with a small update to Mobility released in 0.3.2, all features of the Sequel backends are now working with this version as well.

Support new ActiveRecord Dirty methods

In Rails 5.1, new dirty tracking methods were added to differentiate between changes that have been persisted (saved) to the database and those that have only been changed in-memory (set but not yet saved).

There is a new method saved_changes to return changes to attributes that have been saved, as well as attribute-specific methods (for an attribute title):

  • saved_change_to_title?
  • saved_change_to_title
  • title_before_last_save
  • will_save_change_to_title?
  • title_change_to_be_saved
  • title_in_database

Support for these methods has now been added. Just enable the dirty plugin and the methods will be available for translated attributes on your model.

Fallbacks when Locale is Specified

Paul noticed that the way locale accessors and fallbacks interacted was not very intuitive in version 0.2, and did not correspond to how Globalize works (and to users' typical expectations).

Here’s an example where we are setting the title in English, then calling title_ja:

class Event < ApplicationRecord
  extend Mobility
  translates :title, fallbacks: { en: :ja, ja: :en }
end

Event.new(title_en: "foo").title_ja
#=> "foo" instead of nil

Although fallbacks are enabled from Japanese to English, since we are explicitly specifying the locale we want (Japanese) in title_ja, one would reasonably expect the fallbacks to not take effect, but in fact they do.

This has been fixed in 0.3, so that you get the value in the locale you specified, without falling back to other locales:

Event.new(title_en: "foo").title_ja
#=> nil

This also applies when you fetch an attribute in a locale using the getter options hash:

Event.new(title_en: "foo").title(locale: :ja)
#=> nil

This is a really nice fix since as a byproduct it also makes the dirty plugin work more naturally with fallbacks (showing changes from the previous value without fallbacks).

Duplicate Translations when Duplicating Model

Paul noticed that the ActiveRecord Table backend does not duplicate translations when dup is called on the model. This is different from Globalize, which duplicates the translations as well.

After some discussion, Paul went ahead and implemented this feature by patching initialize_dup. So as of version 0.3, if you dup your translated model instance, you’ll get a copy both of the model and any translations.

Most other backends work without any such patch, since they map to columns on the model itself. However, Paul noticed that the KeyValue backends (which like the Table backend store translations in associations) were affected by the same issue. I have since followed-up and also fixed this issue for the AR KeyValue backend.

I have just released this fix in 0.3.3, so if you update to the latest release, you should have no dup-ing issues with ActiveRecord models for any backend. Sequel handles duplicating slightly differently, so will require more attention; a fix should be out in the next minor release.

Invalidate Cache Key

This is another one by Paul. Up to version 0.2, Mobility would not “touch” the associated translations for the Table and KeyValue backends. This results in problems with caching, since ActiveRecord doesn’t know that the attributes have changed.

The fix we decided on for this issue is simply to add a touch: true to the association defined for translations on the model. With this change, updating a translation will update the updated_at timestamp on the model. Unlike Globalize, which includes this setting as an option, as of version 0.3, Mobility sets touch: true by default, since there is not conceivable reason we could see that you would not want this enabled.

Convert AttributeMethods to a Plugin

One of the tricky parts of building a gem like Mobility is deciding how far to go in overriding core methods of the ORM.

There is a fine line you need to draw. Naturally, you want to make translated attributes behave just like normal attributes, since this is natural for the user and means they don’t need to think too much about them.

However, if you go to far with this, then the ORM (typically ActiveRecord) gets confused and thinks they are actually table columns, with unpredictable consequences. This has happened with Mobility and happens frequently with Globalize, which does much more patching. The results can be very hard to debug.

One of the ways that Mobility patched ActiveRecord previous to version 0.3 was to add translated attributes to the attributes hash:

# Version < 0.3
class Post < ApplicationRecord
  extend Mobility
  translates :title
end

post = Post.new
post.title = "foo"
post.attributes
#=> {"id"=>nil, "created_at"=>nil, "updated_at"=>nil, "title"=>"foo"}

Notice that the last key in the hash returned by post.attributes is title, although title is not actually a table column.

This may seem nice, but in practice it can cause problems both with ActiveRecord itself as well as with other gems. Since title is a key in this hash, a gem might reasonably assume that you can call read_attirbute("title") and get the value of the title, but Mobility (unlike Globalize) does not patch read_attribute to do this, so this fails.

To avoid compatibility issues, this feature of overriding attributes has now been extracted into a plugin, which by default is off, so the instance and model above would not by include title as a key in attributes:

post.attributes
#=> {"id"=>nil, "created_at"=>nil, "updated_at"=>nil}

You can enable it by passing attribute_methods: true to translates (or enabling it by default in the default_options configuration (see below).

# Version 0.3, with attribute_methods plugin enabled
class Post < ApplicationRecord
  extend Mobility
  translates :title, attribute_methods: true
end

post = Post.new
post.title = "foo"
post.attributes
#=> {"id"=>nil, "created_at"=>nil, "updated_at"=>nil, "title"=>"foo"}

Although you can do this with the plugin, in general unless you know you need it, my recommendation would be not to enable attribute methods since it can cause conflicts with other gems. For example, see this issue about a compatibility conflict with the strip_attributes gem.

Deprecate Default Options Setter

The last change is a deprecation, not always the most exciting thing for users of a gem. However, this is an important one. Reasons for the change are described in this issue.

In a nutshell, versions of Mobility prior to 0.3 would allow you to set a hash of default options, which would be merged with whatever options you pass to translates from any model:

Mobility.configure do |config|
  config.default_options = {
    dirty: true,
    fallbacks: true,
    # ...
  }
end

The problem with this is that, by directly setting the defaults, you override the “default defaults”, as it were. These defaults can be found here, and you can see some plugin settings that might not be familiar.

One of these is for a “presence” plugin which is on by default. This plugin simply filters any values set or fetched from the backend to filter out blank strings. I noticed that some users had blanks in their data, which should not happen if this plugin is enabled; it turns out, the reason is because they had set the default_options hash directly, without passing any value for the presence key, thus implicitly turning it off.

To avoid this from happening, default_options= (the setter) has been deprecated in version 0.3. If you are currently setting it, you will see a warning like this:

WARNING: The default_options= setter has been deprecated.
Set each option on the default_options hash instead, like this:

  config.default_options[:fallbacks] = { ... }
  config.default_options[:dirty] = true

This setter method will be removed in the next minor release. To get rid of the deprecation warning, set defaults for each key on the default_options hash, like this:

Mobility.configure do |config|
  config.default_options[:dirty] = true
  config.default_options[:fallbacks] = true
  # ...
end

Although this requires a few more keystrokes, it avoids the problem of overriding the “default defaults” stored initially in config.default_options. I think this is a saner way to set defaults, ensuring that you only opt-out of important plugins if you explicitly set the default.

Looking Ahead: A Christmas Release

The next release of Mobility will most likely be for the first major version (1.0.0), which I’m aiming to finish in time for Christmas. There will be a couple of breaking changes in this release, mostly just method renaming which should only affect some users and will be easy to fix.

As mentioned above, I’m eager to get any feedback and suggestions. Please join the gitter community or post your questions to StackOverflow. If you’re building a gem which depends on translations and are considering using Mobility, please contact me since this is a use case I’m particularly interested in.

Thanks for everyone’s support, and stay tuned for the next release!

Notes

1 NASA Orbital ATK CRS-8 Launch (ref)

Posted on December 5th, 2017. Filed under: translation, mobility, ruby, rails, i18n.
blog comments powered by Disqus