Giter Club home page Giter Club logo

mobility's Introduction

Mobility

Gem Version Build Status Code Climate

This is the readme for version 1.x of Mobility. If you are using an earlier version (0.8.x or earlier), you probably want the readme on the 0-8 branch.

Mobility is a gem for storing and retrieving translations as attributes on a class. These translations could be the content of blog posts, captions on images, tags on bookmarks, or anything else you might want to store in different languages. For examples of what Mobility can do, see the Companies using Mobility section below.

Storage of translations is handled by customizable "backends" which encapsulate different storage strategies. The default way to store translations is to put them all in a set of two shared tables, but many alternatives are also supported, including translatable columns and model translation tables, as well as database-specific storage solutions such as json/jsonb and Hstore (for PostgreSQL).

Mobility is a cross-platform solution, currently supporting both ActiveRecord and Sequel ORM, with support for other platforms planned.

For a detailed introduction to Mobility, see Translating with Mobility. See also my talk at RubyConf 2018, Building Generic Software, where I explain the thinking behind Mobility's design.

If you're coming from Globalize, be sure to also read the Migrating from Globalize section of the wiki.

Installation

Add this line to your application's Gemfile:

gem 'mobility', '~> 1.3.0.rc3'

ActiveRecord (Rails)

Requirements:

  • ActiveRecord >= 6.1

To translate attributes on a model, extend Mobility, then call translates passing in one or more attributes as well as a hash of options (see below).

If using Mobility in a Rails project, you can run the generator to create an initializer and a migration to create shared translation tables for the default KeyValue backend:

rails generate mobility:install

(If you do not plan to use the default backend, you may want to use the --without_tables option here to skip the migration generation.)

The generator will create an initializer file config/initializers/mobility.rb which looks something like this:

Mobility.configure do

  # PLUGINS
  plugins do
    backend :key_value

    active_record

    reader
    writer

    # ...
  end
end

Each method call inside the block passed to plugins declares a plugin, along with an optional default. To use a different default backend, you can change the default passed to the backend plugin, like this:

 Mobility.configure do

   # PLUGINS
   plugins do
-    backend :key_value
+    backend :table

See other possible backends in the backends section.

You can also set defaults for backend-specific options. Below, we set the default type option for the KeyValue backend to :string.

 Mobility.configure do

   # PLUGINS
   plugins do
-    backend :key_value
+    backend :key_value, type: :string
   end
 end

We will assume the configuration above in the examples that follow.

See Getting Started to get started translating your models.

Sequel

Requirements:

  • Sequel >= 4.0

When configuring Mobility, ensure that you include the sequel plugin:

 plugins do
   backend :key_value

-    active_record
+    sequel

You can extend Mobility just like in ActiveRecord, or you can use the mobility plugin, which does the same thing:

class Word < ::Sequel::Model
  plugin :mobility
  translates :name, :meaning
end

Otherwise everything is (almost) identical to AR, with the exception that there is no equivalent to a Rails generator, so you will need to create the migration for any translation table(s) yourself, using Rails generators as a reference.

The models in examples below all inherit from ApplicationRecord, but everything works exactly the same if the parent class is Sequel::Model.

Usage

Getting Started

Once the install generator has been run to generate translation tables, using Mobility is as easy as adding a few lines to any class you want to translate. Simply pass one or more attribute names to the translates method with a hash of options, like this:

class Word < ApplicationRecord
  extend Mobility
  translates :name, :meaning
end

Note: When using the KeyValue backend, use the options hash to pass each attribute's type:

class Word < ApplicationRecord
  extend Mobility
  translates :name,    type: :string
  translates :meaning, type: :text
end

This is important because this is how Mobility knows to which of the two translation tables it should save your translation.

You now have translated attributes name and meaning on the model Word. You can set their values like you would any other attribute:

word = Word.new
word.name = "mobility"
word.meaning = "(noun): quality of being changeable, adaptable or versatile"
word.name
#=> "mobility"
word.meaning
#=> "(noun): quality of being changeable, adaptable or versatile"
word.save
word = Word.first
word.name
#=> "mobility"
word.meaning
#=> "(noun): quality of being changeable, adaptable or versatile"

Presence methods are also supported:

word.name?
#=> true
word.name = nil
word.name?
#=> false
word.name = ""
word.name?
#=> false

What's different here is that the value of these attributes changes with the value of I18n.locale:

I18n.locale = :ja
word.name
#=> nil
word.meaning
#=> nil

The name and meaning of this word are not defined in any locale except English. Let's define them in Japanese and save the model:

word.name = "モビリティ"
word.meaning = "(名詞):動きやすさ、可動性"
word.name
#=> "モビリティ"
word.meaning
#=> "(名詞):動きやすさ、可動性"
word.save

Now our word has names and meanings in two different languages:

word = Word.first
I18n.locale = :en
word.name
#=> "mobility"
word.meaning
#=> "(noun): quality of being changeable, adaptable or versatile"
I18n.locale = :ja
word.name
#=> "モビリティ"
word.meaning
#=> "(名詞):動きやすさ、可動性"

Internally, Mobility is mapping the values in different locales to storage locations, usually database columns. By default these values are stored as keys (attribute names) and values (attribute translations) on a set of translation tables, one for strings and one for text columns, but this can be easily changed and/or customized (see the Backends section below).

Getting and Setting Translations

The easiest way to get or set a translation is to use the getter and setter methods described above (word.name and word.name=), enabled by including the reader and writer plugins.

You may also want to access the value of an attribute in a specific locale, independent of the current value of I18n.locale (or Mobility.locale). There are a few ways to do this.

The first way is to define locale-specific methods, one for each locale you want to access directly on a given attribute. These are called "locale accessors" in Mobility, and can be enabled by including the locale_accessors plugin, with a default set of accessors:

 plugins do
   # ...
+  locale_accessors [:en, :ja]

You can also override this default from translates in any model:

class Word < ApplicationRecord
  extend Mobility
  translates :name, locale_accessors: [:en, :ja]
end

Since we have enabled locale accessors for English and Japanese, we can access translations for these locales with name_en and name_ja:

word.name_en
#=> "mobility"
word.name_ja
#=> "モビリティ"
word.name_en = "foo"
word.name
#=> "foo"

Other locales, however, will not work:

word.name_ru
#=> NoMethodError: undefined method `name_ru' for #<Word id: ... >

With no plugin option (or a default of true), Mobility generates methods for all locales in I18n.available_locales at the time the model is first loaded.

An alternative to using the locale_accessors plugin is to use the fallthrough_accessors plugin. This uses Ruby's method_missing method to implicitly define the same methods as above, but supporting any locale without any method definitions. (Locale accessors and fallthrough locales can be used together without conflict, with locale accessors taking precedence if defined for a given locale.)

Ensure the plugin is enabled:

 plugins do
   # ...
+  fallthrough_accessors

... then we can access any locale we want, without specifying them upfront:

word = Word.new
word.name_fr = "mobilité"
word.name_fr
#=> "mobilité"
word.name_ja = "モビリティ"
word.name_ja
#=> "モビリティ"

(Note however that Mobility will complain if you have I18n.enforce_available_locales set to true and you try accessing a locale not present in I18n.available_locales; set it to false if you want to allow any locale.)

Another way to fetch values in a locale is to pass the locale option to the getter method, like this:

word.name(locale: :en)
#=> "mobility"
word.name(locale: :fr)
#=> "mobilité"

Note that setting the locale this way will pass an option locale: true to the backend and all plugins. Plugins may use this option to change their behavior (passing the locale explicitly this way, for example, disables fallbacks, see below for details).

You can also set the value of an attribute this way; however, since the word.name = <value> syntax does not accept any options, the only way to do this is to use send (this is included mostly for consistency):

word.send(:name=, "mobiliteit", locale: :nl)
word.name_nl
#=> "mobiliteit"

Yet another way to get and set translated attributes is to call read and write on the storage backend, which can be accessed using the method <attribute>_backend. Without worrying too much about the details of how this works for now, the syntax for doing this is simple:

word.name_backend.read(:en)
#=> "mobility"
word.name_backend.read(:nl)
#=> "mobiliteit"
word.name_backend.write(:en, "foo")
word.name_backend.read(:en)
#=> "foo"

Internally, all methods for accessing translated attributes ultimately end up reading and writing from the backend instance this way. (The write methods do not call underlying backend's methods to persist the change. This is up to the user, so e.g. with ActiveRecord you should call save write the changes to the database).

Note that accessor methods are defined in an included module, so you can wrap reads or writes in custom logic:

class Post < ApplicationRecord
  extend Mobility
  translates :title

  def title(*)
    super.reverse
  end
end

Setting the Locale

It may not always be desirable to use I18n.locale to set the locale for content translations. For example, a user whose interface is in English (I18n.locale is :en) may want to see content in Japanese. If you use I18n.locale exclusively for the locale, you will have a hard time showing stored translations in one language while showing the interface in another language.

For these cases, Mobility also has its own locale, which defaults to I18n.locale but can be set independently:

I18n.locale = :en
Mobility.locale              #=> :en
Mobility.locale = :fr
Mobility.locale              #=> :fr
I18n.locale                  #=> :en

To set the Mobility locale in a block, you can use Mobility.with_locale (like I18n.with_locale):

Mobility.locale = :en
Mobility.with_locale(:ja) do
  Mobility.locale            #=> :ja
end
Mobility.locale              #=> :en

Mobility uses RequestStore to reset these global variables after every request, so you don't need to worry about thread safety. If you're not using Rails, consult RequestStore's README for details on how to configure it for your use case.

Fallbacks

Mobility offers basic support for translation fallbacks. First, enable the fallbacks plugin:

 plugins do
   # ...
+  fallbacks
+  locale_accessors

Fallbacks will require fallthrough_accessors to handle methods like title_en, which are used to track changes. For performance reasons it's generally best to also enable the locale_accessors plugin as shown above.

Now pass a hash with fallbacks for each locale as an option when defining translated attributes on a class:

class Word < ApplicationRecord
  extend Mobility
  translates :name,    fallbacks: { de: :ja, fr: :ja }
  translates :meaning, fallbacks: { de: :ja, fr: :ja }
end

Internally, Mobility assigns the fallbacks hash to an instance of I18n::Locale::Fallbacks.new.

By setting fallbacks for German and French to Japanese, values will fall through to the Japanese value if none is present for either of these locales, but not for other locales:

Mobility.locale = :ja
word = Word.create(name: "モビリティ", meaning: "(名詞):動きやすさ、可動性")
Mobility.locale = :de
word.name
#=> "モビリティ"
word.meaning
#=> "(名詞):動きやすさ、可動性"
Mobility.locale = :fr
word.name
#=> "モビリティ"
word.meaning
#=> "(名詞):動きやすさ、可動性"
Mobility.locale = :ru
word.name
#=> nil
word.meaning
#=> nil

You can optionally disable fallbacks to get the real value for a given locale (for example, to check if a value in a particular locale is set or not) by passing fallback: false (singular, not plural) to the getter method:

Mobility.locale = :de
word.meaning(fallback: false)
#=> nil
Mobility.locale = :fr
word.meaning(fallback: false)
#=> nil
Mobility.locale = :ja
word.meaning(fallback: false)
#=> "(名詞):動きやすさ、可動性"

You can also set the fallback locales for a single read by passing one or more locales:

Mobility.with_locale(:fr) do
  word.meaning = "(nf): aptitude à bouger, à se déplacer, à changer, à évoluer"
end
word.save
Mobility.locale = :de
word.meaning(fallback: false)
#=> nil
word.meaning(fallback: :fr)
#=> "(nf): aptitude à bouger, à se déplacer, à changer, à évoluer"
word.meaning(fallback: [:ja, :fr])
#=> "(名詞):動きやすさ、可動性"

Also note that passing a locale option into an attribute reader or writer, or using locale accessors or fallthrough accessors to get or set any attribute value, will disable fallbacks (just like fallback: false). (This will take precedence over any value of the fallback option.)

Continuing from the last example:

word.meaning(locale: :de)
#=> nil
word.meaning_de
#=> nil
Mobility.with_locale(:de) { word.meaning }
#=> "(名詞):動きやすさ、可動性"

For more details, see the API documentation on fallbacks and this article on I18n fallbacks.

Default values

Another option is to assign a default value, using the default plugin:

 plugins do
   # ...
+  default 'foo'

Here we've set a "default default" of 'foo', which will be returned if a fetch would otherwise return nil. This can be overridden from model classes:

class Word < ApplicationRecord
  extend Mobility
  translates :name, default: 'foo'
end

Mobility.locale = :ja
word = Word.create(name: "モビリティ")
word.name
#=> "モビリティ"
Mobility.locale = :de
word.name
#=> "foo"

You can override the default by passing a default option to the attribute reader:

word.name
#=> 'foo'
word.name(default: nil)
#=> nil
word.name(default: 'bar')
#=> 'bar'

The default can also be a Proc, which will be called with the context as the model itself, and passed optional arguments (attribute, locale and options passed to accessor) which can be used to customize behaviour. See the API docs for details.

Dirty Tracking

Dirty tracking (tracking of changed attributes) can be enabled for models which support it. Currently this is models which include ActiveModel::Dirty (like ActiveRecord::Base) and Sequel models (through the dirty plugin).

First, ensure the dirty plugin is enabled in your configuration, and that you have enabled an ORM plugin (either active_record or sequel), since the dirty plugin will depend on one of these being enabled.

 plugins do
   # ...
   active_record
+  dirty

(Once enabled globally, the dirty plugin can be selectively disabled on classes by passing dirty: false to translates.)

Take this ActiveRecord class:

class Post < ApplicationRecord
  extend Mobility
  translates :title
end

Let's assume we start with a post with a title in English and Japanese:

post = Post.create(title: "Introducing Mobility")
Mobility.with_locale(:ja) { post.title = "モビリティの紹介" }
post.save

Now let's change the title:

post = Post.first
post.title                      #=> "Introducing Mobility"
post.title = "a new title"
Mobility.with_locale(:ja) do
  post.title                    #=> "モビリティの紹介"
  post.title = "新しいタイトル"
  post.title                    #=> "新しいタイトル"
end

Now you can use dirty methods as you would any other (untranslated) attribute:

post.title_was
#=> "Introducing Mobility"
Mobility.locale = :ja
post.title_was
#=> "モビリティの紹介"
post.changed
["title_en", "title_ja"]
post.save

You can also access previous_changes:

post.previous_changes
#=>
{
  "title_en" =>
    [
      "Introducing Mobility",
      "a new title"
    ],
  "title_ja" =>
    [
      "モビリティの紹介",
      "新しいタイトル"
    ]
}

Notice that Mobility uses locale suffixes to indicate which locale has changed; dirty tracking is implemented this way to ensure that it is clear what has changed in which locale, avoiding any possible ambiguity.

For performance reasons, it is highly recommended that when using the Dirty plugin, you also enable locale accessors for all locales which will be used, so that methods like title_en above are defined; otherwise they will be caught by method_missing (using fallthrough accessors), which is much slower.

For more details on dirty tracking, see the API documentation.

Cache

The Mobility cache caches localized values that have been fetched once so they can be quickly retrieved again. The cache plugin is included in the default configuration created by the install generator:

 plugins do
   # ...
+  cache

It can be disabled selectively per model by passing cache: false when defining an attribute, like this:

class Word < ApplicationRecord
  extend Mobility
  translates :name, cache: false
end

You can also turn off the cache for a single fetch by passing cache: false to the getter method, i.e. post.title(cache: false). To remove the cache plugin entirely, remove the cache line from the global plugins configuration.

The cache is normally just a hash with locale keys and string (translation) values, but some backends (e.g. KeyValue and Table backends) have slightly more complex implementations.

Querying

Mobility backends also support querying on translated attributes. To enable this feature, include the query plugin, and ensure you also have an ORM plugin enabled (active_record or sequel):

 plugins do
   # ...
   active_record
+  query

Querying defines a scope or dataset class method, whose default name is i18n. You can override this by passing a default in the configuration, like query :t to use a name t.

Querying is supported in two different ways. The first is via query methods like where (and not and find_by in ActiveRecord, and except in Sequel).

So for ActiveRecord, assuming a model using KeyValue as its default backend:

class Post < ApplicationRecord
  extend Mobility
  translates :title,   type: :string
  translates :content, type: :text
end

... we can query for posts with title "foo" and content "bar" just as we would query on untranslated attributes, and Mobility will convert the queries to whatever the backend requires to actually return the correct results:

Post.i18n.find_by(title: "foo", content: "bar")

results in the SQL:

SELECT "posts".* FROM "posts"
INNER JOIN "mobility_string_translations" "Post_title_en_string_translations"
  ON "Post_title_en_string_translations"."key" = 'title'
  AND "Post_title_en_string_translations"."locale" = 'en'
  AND "Post_title_en_string_translations"."translatable_type" = 'Post'
  AND "Post_title_en_string_translations"."translatable_id" = "posts"."id"
INNER JOIN "mobility_text_translations" "Post_content_en_text_translations"
  ON "Post_content_en_text_translations"."key" = 'content'
  AND "Post_content_en_text_translations"."locale" = 'en'
  AND "Post_content_en_text_translations"."translatable_type" = 'Post'
  AND "Post_content_en_text_translations"."translatable_id" = "posts"."id"
WHERE "Post_title_en_string_translations"."value" = 'foo'
  AND "Post_content_en_text_translations"."value" = 'bar'

As can be seen in the query above, behind the scenes Mobility joins two tables, one with string translations and one with text translations, and aliases the joins for each attribute so as to match the particular model, attribute(s), locale(s) and value(s) passed in to the query. Details of how this is done can be found in the Wiki page for the KeyValue backend.

You can also use methods like order, select, pluck and group on translated attributes just as you would with normal attributes, and Mobility will handle generating the appropriate SQL:

Post.i18n.pluck(:title)
#=> ["foo", "bar", ...]

If you would prefer to avoid the i18n scope everywhere, you can define it as a default scope on your model:

class Post < ApplicationRecord
  extend Mobility
  translates :title,   type: :string
  translates :content, type: :text
  default_scope { i18n }
end

Now translated attributes can be queried just like normal attributes:

Post.find_by(title: "Introducing Mobility")
#=> finds post with English title "Introducing Mobility"

If you want more fine-grained control over your queries, you can alternatively pass a block to the query method and call attribute names from the block scope to build Arel predicates:

Post.i18n do
  title.matches("foo").and(content.matches("bar"))
end

which generates the same SQL as above, except the WHERE clause becomes:

SELECT "posts".* FROM "posts"
  ...
WHERE "Post_title_en_string_translations"."value" ILIKE 'foo'
  AND "Post_content_en_text_translations"."value" ILIKE 'bar'

The block-format query format is very powerful and allows you to build complex backend-independent queries on translated and untranslated attributes without having to deal with the details of how these translations are stored. The same interface is supported with Sequel to build datasets.

Backends

Mobility supports different storage strategies, called "backends". The default backend is the KeyValue backend, which stores translations in two tables, by default named mobility_text_translations and mobility_string_translations.

You can set the default backend to a different value in the global configuration, or you can set it explicitly when defining a translated attribute, like this:

class Word < ApplicationRecord
  translates :name, backend: :table
end

This would set the name attribute to use the Table backend (see below). The type option (type: :string or type: :text) is missing here because this is an option specific to the KeyValue backend (specifying which shared table to store translations on). Backends have their own specific options; see the Wiki and API documentation for which options are available for each.

Everything else described above (fallbacks, dirty tracking, locale accessors, caching, querying, etc) is the same regardless of which backend you use.

Table Backend (like Globalize)

The Table backend stores translations as columns on a model-specific table. If your model uses the table posts, then by default this backend will store an attribute title on a table post_translations, and join the table to retrieve the translated value.

To use the table backend on a model, you will need to first create a translation table for the model, which (with Rails) you can do using the mobility:translations generator:

rails generate mobility:translations post title:string content:text

This will generate the post_translations table with columns title and content, and all other necessary columns and indices. For more details see the Table Backend page of the wiki and API documentation on the Mobility::Backend::Table class.

Column Backend (like Traco)

The Column backend stores translations as columns with locale suffixes on the model table. For an attribute title, these would be of the form title_en, title_fr, etc.

Use the mobility:translations generator to add columns for locales in I18n.available_locales to your model:

rails generate mobility:translations post title:string content:text

For more details, see the Column Backend page of the wiki and API documentation on the Mobility::Backend::Column class.

PostgreSQL-specific Backends

Mobility also supports JSON and Hstore storage options, if you are using PostgreSQL as your database. To use this option, create column(s) on the model table for each translated attribute, and set your backend to :json, :jsonb or :hstore. If you are using Sequel, note that you will need to enable the pg_json or pg_hstore extensions with DB.extension :pg_json or DB.extension :pg_hstore (where DB is your database instance).

Another option is to store all your translations on a single jsonb column (one per model). This is called the "container" backend.

For details on these backends, see the Postgres Backend and Container Backend pages of the wiki and in the API documentation (Mobility::Backend::Jsonb and Mobility::Backend::Hstore).

Note: The Json backend (:json) may also work with recent versions of MySQL with JSON column support, although this backend/db combination is not tested. See this issue for details.

Development

Custom Backends

Although Mobility is primarily oriented toward storing ActiveRecord model translations, it can potentially be used to handle storing translations in other formats. In particular, the features mentioned above (locale accessors, caching, fallbacks, dirty tracking to some degree) are not specific to database storage.

To use a custom backend, simply pass the name of a class which includes Mobility::Backend to translates:

class MyBackend
  include Mobility::Backend
  # ...
end

class MyClass
  extend Mobility
  translates :foo, backend: MyBackend
end

For details on how to define a backend class, see the Introduction to Mobility Backends page of the wiki and the API documentation on the Mobility::Backend module.

Testing Backends

All included backends are tested against a suite of shared specs which ensure they conform to the same expected behaviour. These examples can be found in:

  • spec/support/shared_examples/accessor_examples.rb (minimal specs testing translation setting/getting)
  • spec/support/shared_examples/querying_examples.rb (specs for querying)
  • spec/support/shared_examples/serialization_examples.rb (specialized specs for backends which store translations as a Hash: serialized, hstore, json and jsonb backends)

A minimal test can simply define a model class and use helpers defined in spec/support/helpers.rb to run these examples, by extending either Helpers::ActiveRecord or Helpers::Sequel:

describe MyBackend do
  extend Helpers::ActiveRecord

  before do
    stub_const 'MyPost', Class.new(ActiveRecord::Base)
    MyPost.extend Mobility
    MyPost.translates :title, :content, backend: MyBackend
  end

  include_accessor_examples 'MyPost'
  include_querying_examples 'MyPost'
  # ...
end

Shared examples expect the model class to have translated attributes title and content, and an untranslated boolean column published. These defaults can be changed, see the shared examples for details.

Backends are also each tested against specialized specs targeted at their particular implementations.

Integrations

Tutorials

More Information

Companies using Mobility

Logos of companies using Mobility

Post an issue or email me to add your company's name to this list.

License

The gem is available as open source under the terms of the MIT License.

mobility's People

Contributors

artplan1 avatar banzaiman avatar biow0lf avatar dlcmh avatar doits avatar fluke avatar georgegorbanev avatar gitter-badger avatar jonian avatar kalsan avatar lime avatar madis avatar matiaskorhonen avatar mauriciopasquier avatar mival avatar mmizutani avatar morozred avatar mrbrdo avatar olleolleolle avatar omitter avatar petergoldstein avatar phil-allcock avatar pwim avatar raulr avatar sachin21 avatar sedubois avatar sergey-alekseev avatar shioyama avatar thatguysimon avatar valscion avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

mobility's Issues

Issue with Mobility as plugin dependency. Generators don't work.

I've been trying to use Mobility in my Rails plugin.

To reproduce the error create a new plugin:

rails plugin new my_plugin --mountable

If I run this from my_plugin root

rails generate mobility:install

it returns this error

Could not find generator 'mobility:install'. Maybe you meant 'mobility:install', 'mobility:translations' or 'test_unit:system'

I also opened a stackoverflow question

Disable setting default_options hash directly

Context

Since 0.2.0, it has become possible to set Mobility.default_options which are merged into the options set whenever translates is called on a Mobility model. However, the problem with this is that the "default" default_options is overridden if the user sets these options with Mobility.default_options =, which will typically disable "low-profile" plugins like presence and cache.

Expected Behavior

Setting Mobility.default_options = raises an error and instructs the user to set options by their key, e.g.:

Mobility.configure do |config|
  # ...

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

Actual Behavior

Mobility allows default options to be overridden entirely, implicitly disabling any options which were previously true but now are nil.

Possible Fix

Add a deprecation warning on the setter in 0.3.0, and remove thereafter.

Fix AM/AR::Dirty plugin compatibility issues with AR 5.2

Context

ActiveRecord 5.2.beta.2 has just been released, and we are now testing against it (see #112). There are about twenty specs failing currently, mostly around the Dirty plugin.

The main issue seems to be with rails/rails#28661 (fixing rails/rails#28660), which replaced a __send__(attr) to _attributes(attr), and ultimately to _read_attribute(attr). This is problematic since while in ActiveModel _read_attribute simply maps to __send__, which for a localized accessor like title_en will correctly return the current value of the title, in AR it maps to this:

def _read_attribute(attr_name, &block) # :nodoc
  @attributes.fetch_value(attr_name.to_s, &block)
end

Here, @attributes will not contain the localized translated attributes, so title_en here will return nil. So most of the AR::Dirty specs are failing with changes all coming out as [nil, nil] instead of e.g. [nil, "foo"].

Possible Fix

I'm currently investigating if we could override _read_attribute to do something special for translated attributes, but it's tricky because in the dirty plugin we're using localized attributes to track changes in multiple locales. This is tricky since without a complex regex, we can't really tell if a given attribute passed to _read_attribute is actually a translated attribute or not.

I don't want to be matching regexes every time _read_attribute is called since this is a very frequently-used method and regexes are slow, so this could significantly impact performance. For now just tracking the problem here, I'll see if there are other ways to fix the problem.

Getting MissingAttributeError when submitting a value in the form with trailing whitespace

I have a Page model that translates :title, :navigation_title, :lead, :content.

class Page < ApplicationRecord
  extend Mobility
  translates :title, :navigation_title, :lead, :content
end

I'm using the following config:

Mobility.configure do |config|
  config.default_backend = :column
  config.accessor_method = :translates
  config.query_method    = :i18n
  config.default_options = {
    fallbacks: { de: :en }
  }
end

I noticed a strange behaviour: whenever I submit a value using the form with a trailing whitespace (e.g. _x, _x_, or x_, where whitespace is substituted with an underscore) to a translated field (e.g. lead) I get the following error:

ActiveModel::MissingAttributeError at /de/pages/1
can't write unknown attribute `lead`

If I submit any other value (e.g. x`), it works perfectly.

This only happens to translated fields, not to other fields.

Any idea what's going on? I'm happy to provide more details if needed.

Here's the app I'm currently working on: jmuheim/base#86

update_attribute integration buggy

Under Rails 5.0, update_attribute only works for the key_value/table backends if the dirty plugin is enabled.

Under Rails 5.1, update_attribute never works for the key_value/table backends (even if the dirty plugin is enabled).

See #93 for tests that reproduce this bug.

n+1 calls when retrieving all records of a model

One problem I have with the Moblity Gem is that when I wanted to display the translated attributes of all records, a separate call is made to the database for each record
(OR maybe I don't know how to do it correctly; if this is the case, please let me know)

I tried writing a helper method myself, which can be found here:
http://railsmisadventures.blogspot.hk/2017/10/eliminate-n1-calls-using-mobility-gem.html

But I would certainly like this incorporated into the gem itself in someway to make things simpler

Different column name for jsonb backend

It would be nice if the jsonb and hstore backends had a different default column name such as title_translations for a title attribute. This way we are not overriding methods generated by ActiveRecord and we can access it directly without using a work around like read_attribute.

Also read_attribute might be used by other gems as an alternative way to access the value and expect it to be the same as calling the accessor method.

The real motivation for this change is that we can define type_for_attribute or column_for_attribute methods so that other gems can infer the type (such as simple_form and formtastic). Overriding this on a column which already exists might have some bad side effects.

This change would probably require a major version bump so I can understand if it gets rejected.

Query with OR condition on array

Context

Using the jsonb backend with PostgreSQL, I am trying to find all records missing translations of the current locale. In ActiveRecord you can pass an array of values to perform an OR condition.

Product.i18n.where(name: ['', nil])

Expected Behavior

In a normal string column this would find all products whose name is empty or nil.

Actual Behavior

This looks for a product whose name matches ['', nil] json data.

Possible Fix

My current solution is this:

Product.i18n.where(name: '').or(Product.i18n.where(name: nil))

I understand that since this is a jsonb data type ActiveRecord treats the query condition differently, however it would be nice if Mobility kept the behavior of a string column. BTW great gem!

Pass locale and options to proc in Default plugin

Context

The Default plugin can be used to set a default value to fallthrough to in case a translation would otherwise be nil. A Proc can be used in place of a fixed value, and the proc currently is passed the model and attribute name as keyword arguments.

However, the plugin does not pass the locale and accessor options to the proc, so if e.g. you wanted to set a default value in each locale, you wouldn't be able to do that. This seems like a very common use of this plugin so we should pass both the locale and the options hash as well.

Error with initialization

There appears to be a bug on line 257.

mobility/lib/mobility.rb

Lines 248 to 257 in 07a6863

def mobility
@mobility ||= Mobility::Wrapper.new(self)
end
def translated_attribute_names
mobility.translated_attribute_names
end
def inherited(subclass)
subclass.instance_variable_set(:@mobility, @mobility.dup)

Rails was crashing when autoloading a model on @mobility.dup, as @mobility was nil. Because this is a loading issue, I don't think it is easy to reproduce. I'm guessing you mean mobility.dup, which appears to resolve the issue for me.

JSONB error using Sequel 4 and Hanami

RE: #75
There are two different errors popping up as you try to add a new item in a model that has mobility loaded and pointed at the jsonb columns.

Context

Gems:
hanami 1.0.0
sequel (4.49.0)
sequel-annotate (1.0.0)
sequel-rake (0.1.0)
sequel_pg (1.7.0)
sequel_polymorphic (0.3.1)
sequel_sluggable (0.0.6)

Columns are created with:
jsonb :synopsis

Except for title who has null: false

Model:

  translates :title, type: :string
  translates :synopsis, type: :text

Expected Behavior

Well, it should create the item.

Actual Behavior

When creating an item with a null column which is jsonb and mobility pointed at:
Sequel::Error: The AND operator requires at least 1 argument

When specifying data for both jsonb columns (title and synopsis):

2.3.3 :004 > 
m.synopsis = "test"
 => "test" 
2.3.3 :005 > m.save
Sequel::DatabaseError: PG::DatatypeMismatch: ERROR:  column "synopsis" is of type jsonb but expression is of type boolean
LINE 1: ...gas" ("synopsis", "title", "created_at") VALUES (('en' = 'te...
                                                             ^
HINT:  You will need to rewrite or cast the expression.

This hits both translated columns it just seems to put the error out on the last one though.
If I comment out the translates one on synopsis it hits the title one.

Possible Fix

Might be Sequel's fault.

Set a default to be used when there is no translation for a locale

I’m using the jsonb backend to translate an array. This is how it looks natively.

translators: {"fr"=>["Foo Bar"]}

I want to be able to treat translators always as an array (calling .to_sentence), but I can’t do that if it returns nil when the locale is en. Is there any way to set a default like []?

Uniqueness validator returns exists for non existing records

Hi, thank you for this great gem.

When I use uniqueness: true in validations, I get the following message. I have tried using it with an existing database and a new database.

Running via Spring preloader in process 16712
Loading development environment (Rails 5.1.1)
[1] pry(main)> Category.create(name: 'CSR')
   (0.2ms)  BEGIN
  Mobility::ActiveRecord::StringTranslation Exists (1.3ms)  SELECT  1 AS one FROM "mobility_string_translations" WHERE "mobility_string_translations"."key" = $1 AND "mobility_string_translations"."translatable_id" IS NULL AND "mobility_string_translations"."translatable_type" = $2 AND "mobility_string_translations"."locale" = $3 LIMIT $4  [["key", "name"], ["translatable_type", "Category"], ["locale", "en"], ["LIMIT", 1]]
   (0.2ms)  ROLLBACK
ActiveRecord::ImmutableRelation: ActiveRecord::ImmutableRelation
from /home/jonian/.gem/ruby/2.4.0/gems/activerecord-5.1.1/lib/active_record/relation/query_methods.rb:936:in `assert_mutability!'

I am using the Key-Store backend, PostgreSQL 9.6.3, Rails 5.1.1 and Ruby 2.4.1.
The model configuration is below:

class Category < ApplicationRecord
  # Include modules
  include Mobility

  # Translate attributes
  translates :name, type: :string, locale_accessors: true, fallbacks: true

  # Attribute validations
  validates :name, presence: true, uniqueness: true
end

class ApplicationRecord < ActiveRecord::Base
  # Set class as abstract
  self.abstract_class = true
end

Query with fallbacks

i.e.

Mobility.locale = :en
Post.i18n.where(title: "foo")

...should return the post with title "foo" in English if a post with title in English exists, if not it should find a post with title "foo" in one of the fallback locales defined for the title attributes.

Ref: shioyama/friendly_id-mobility#4

Backend decision - use case

I am having troubles deciding whether to use :jsonb or :table as backend.
I use Postgres, and I am having the need of keeping 5+ locales.

let's say I want to use :jsonb and the table looks like this (btw. a wiki on the right creation of the migrations would be very useful):

create_table :posts do |t|
    t.jsonb :title, null: false, default: '{}'
    t.jsonb :content, null: false, default: '{}'
end
add_index  :posts, :title, using: :gin
add_index  :posts, :content, using: :gin

In this case there are two fields that will get two different data.
The first is a title (max 200 chars), and the second is the content of the post (3000+ chars).

The doubt arise from the fact that rails gets the whole columns (3000+ chars) with all the translations at once from the db ({it: ..., es: ..., en: ..., ...}).
Even if, in the end, only one of the locale will be used.

In the case of the title looks ok, while for the content it could be an useless amount of data retrived (imagine 10+ locales).
I would have preferred to get only the required locale from the db (through something like: SELECT content->':locale' as content) but it's not currently possible with rails.

Is this a good practice? Is this a case where using :table (or also :key_value) as backend is preferred, even with postgres? Am I missing something or overthinking over a non-problem?

Ps. I am sorry in advance if this is not the appropriate place where discuss about this.

Defaults set in initializer are not reloaded when application is reloaded

This seems to happen with Spring in a Rails project. When spring reloads, the defaults in the Mobility configuration are reset and not reloaded from the initializer, so you see the exception Backend option required if Mobility.config.default_backend is not set.".

Not yet quite sure why this happens, just posting this for now to remember to fix it.

Table backend migration generator doesn't actually create a table

rails generate mobility:translations project title:string description:string story:string

yields

class CreateProjectTitleAndDescriptionAndStoryTranslationsForMobilityTableBackend < ActiveRecord::Migration[5.1]
  def change
    add_column :project_translations, :title, :string
    add_column :project_translations, :description, :string
    add_column :project_translations, :story, :string
  end
end

Which doesn't work if project_translations table doesn't exist beforehand.

This should be either mentioned in the documentation explicitly or the generator should assume there's no such a table and create an appropriate migration.

Disable I18n.locale when setting attribute values

Hello,

first of all, thanks for this great gem!

I would like to know, if there is a way to simply assign and save a hash to an attribute in such a way that this hash will NOT be stored under the current locales key?

The reason I want to do that is to assign multiple locale values using a nested form and update multiple translations at once.

I use a JSONB column to store translations.

# I18n.locale = :en
# It should set the attribute simply to the given hash
some_post.description = {en:  'Hello World',  de: 'Hallo Welt'} 
some_post.save

# Actual JSONB column value:
{'en': {'en': 'Hello World', 'de': 'Hallo Welt'}}

# Expected JSONB column value:
{'en': 'Hello World', 'de': 'Hallo Welt'}

ActiveRecord querying not working in > 0.1.14

Behaviour in 0.1.14

[4] pry(main)> User.first
  User Load (1.0ms)  SELECT  "users".* FROM "users" ORDER BY "users"."created_at" ASC LIMIT $1  [["LIMIT", 1]]
=> #<User id: "8ec92fd5-5926-4657-9b32-8cf8382de795", email: "[email protected]", created_at: "2017-05-16 10:19:37", updated_at: "2017-05-16 10:19:37", first_name: nil, last_name: nil, guest: false>

Behaviour in 0.1.15 and 0.1.16

[4] pry(main)> User.first
NoMethodError: undefined method `[]' for nil:NilClass
from /Users/<user>/.rvm/gems/ruby-2.4.1/gems/activerecord-5.1.0/lib/active_record/relation/delegation.rb:5:in `relation_delegate_class'

Coming from this line: https://github.com/rails/rails/blob/v5.1.0/activerecord/lib/active_record/relation/delegation.rb#L5

I wanted to upgrade the gem, because I was getting a lot of these deprecation warnings:

DEPRECATION WARNING: The behavior of `changed_attributes` inside of after callbacks will be changing in the next version of Rails. The new return value will reflect the behavior of calling the method after `save` returned (e.g. the opposite of what it returns now). To maintain the current behavior, use `saved_changes.transform_values(&:first)` instead. (called from create_guest_user at /Users/<user>/Desktop/<project>/app/models/user.rb:28)

line causing them is just user.save!, and a colleague thought that they might be coming from mobility. Seems like this is related rails/rails#28640

Support Sequel >= 5.0

There are some specs failing when running against Sequel 5.0 that need to be fixed. We can release Sequel 5.0 compatibility in 0.3.0.

Mysql2::Error on Migration for mobility_string_translations when encoding is utf8mb4

Context

I created the basic migrations with rails generate mobility:install with backend: key_value using mobility version 0.2.3 (Rails 5.1.4).

Expected Behavior

It should run the initial migration.

Actual Behavior

When I do rails db:migrate it fails for

add_index(:mobility_string_translations, [:translatable_type, :key, :value, :locale], {:name=>:index_mobility_string_translations_on_query_keys})

with the following message:

Mysql2::Error: Specified key was too long; max key length is 3072 bytes: CREATE INDEX index_mobility_string_translations_on_query_keys ON mobility_string_translations (translatable_type, key, value, locale)

Possible Fix

This maybe happens because we use utf8mb4-encoding in database.yml

development:
  adapter: mysql2
  encoding: utf8mb4

UTF8mb4 is a 4-Byte character encoding. We prefer it because it is a real UTF-8-encoding (the origininal is missing lots of symbols, see this explanation).
We can now calculate the length of the index:

  • translatable_type: 255*4
  • key: 255*4
  • value: 255*4
  • locale: 255*4
    =============
    index: 4080 Bytes
    which is bigger than 3072.

We can for example make locale varchar(15) (the longest locales I found were 12 characters long).

We then have to save another 12 chars. That can be for example by changing key from varchar(255) to varchar(243).

So the Migration would look like this:

class CreateStringTranslations < ActiveRecord::Migration[5.1]

  def change
    create_table :mobility_string_translations do |t|
      t.string  :locale, limit: 15 # NEW limit option
      t.string  :key, limit: 243   # NEW limit option
      t.string  :value
      t.integer :translatable_id
      t.string  :translatable_type
      t.timestamps
    end
    add_index :mobility_string_translations, [:translatable_id, :translatable_type, :locale, :key], unique: true, name: :index_mobility_string_translations_on_keys
    add_index :mobility_string_translations, [:translatable_id, :translatable_type, :key], name: :index_mobility_string_translations_on_translatable_attribute
    add_index :mobility_string_translations, [:translatable_type, :key, :value, :locale], name: :index_mobility_string_translations_on_query_keys
  end
end

This proposed fix worked for me, but is maybe harmful so I do not make a PR because I don't know what you think about this.

Accessing a locale initializes it

I noticed when I access a given locale with the table backend, a Translation object is also getting initialized. This means I have two translation records for every object I create.

More specifically, I have an Event class like the following.

class Event < ApplicationRecord
  translates :title, backend: :table
  validates :title_en, length: { maximum: 255 }
  validates :title_ja, length: { maximum: 255 }
end

Because I have two validations on the length (maybe there's a better way to do this with mobility?), whenever I create an event, I get two translation records. This behavior doesn't appear to be specific to accessing it within the validation context. If I remove those validations, the following spec still fails.

it do
  event = Event.new(title: "MyTitle")
  expect(event.translations.pluck(:title)).to eq([event.title])
  event.title(locale: :ja)
  event.save!

  # will fail with ["MyTitle", nil]
  expect(event.translations.pluck(:title)).to eq([event.title])
end

The plugins don't seem to effect this. Even with the default options, it still happens. Is this behavior intentional?

Support Rails 5's `saved_changes`

Rails 5 introduced the saved_changes method to the Dirty module, but it seems like Mobility still doesn't support that:

class Post
  include Mobility
  translates :translated_attribute, dirty: true
end

post = Post.first
post.translated_attribute = "test"
post.not_a_translated_attribute = "test"

post.changes 
# => { "translated_attribute" => ["", "test"], "not_a_translated_attribute" => ["", test] }

post.save!

post.saved_changed 
# => { "not_a_translated_attribute" => ["", "test"] }

It would be great if saved_changes also returned the translated attributes.

Thank you.

NameError: uninitialized constant Mobility::ActiveRecord::Base

When Mobility is loaded for ActiveRecord, it includes Mobility::ActiveRecord module

model_class.include(ActiveRecord) if model_class < ::ActiveRecord::Base

If the ActiveRecord::Base subclass that now includes Mobility did something like the following before:

class MyModel < ActiveRecord::Base
  def my_method
    ActiveRecord::Base.uncached do
      # some code
    end
  end
end

It will get an exception raised:

NameError:
  uninitialized constant Mobility::ActiveRecord::Base

It will get an exception because ActiveRecord is found on Mobility module first (the original ActiveRecord from rails is further down in the ancestors chain.

The fix on the user's side would be to qualify top level lookup with ::ActiveRecord but I don't know how happy the users of Mobility would be if they had to do it.

Another would be to rename the ActiveRecord module in Mobility, which would kind of diminish the descriptiveness.

Do you have other suggestions on how to approach this issue?

warning: already initialized constant Mobility::Loaded::Rails

Just some warnings popping up about Rails on Hanami + Sequel.
Hanami 1.0.0
Sequel 4.*
Mobility 0.2.0

/home/vagrant/.rvm/gems/ruby-2.3.3/gems/mobility-0.2.0/lib/mobility.rb:57: warning: already initialized constant Mobility::Loaded::Rails
/home/vagrant/.rvm/gems/ruby-2.3.3/gems/mobility-0.2.0/lib/mobility.rb:53: warning: previous definition of Rails was here

friendly_id support

Is there a way to make mobility work with the friendly_id gem?
I did got it working with the globalize gem, but it seems not to be working with mobility

locale_accessors & fallbacks

When using locale_accessors, the locale specific accessors also use the fallbacks. e.g.,

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

I'm using the locale accessors to generate a form that provides the ability to edit an events title in Japanese and English (in two different form fields). With globalize, the locale specific accessors would return nil. This made it obvious which fields have not been translated. It also meant translations only got written when the user entered them. With mobility's current behaviour, if a user edits an event with an untranslated title and then saves it, the untranslated title will become the original title.

default_fallbacks configuration option doesn't work (AR, Postgres)

Setting default_fallbacks configuration option doesn't have any (visible) effect. Consider the following snippet:

begin
  require 'bundler/inline'
rescue LoadError => e
  $stderr.puts 'Bundler version 1.10 or later is required.'
  raise e
end

gemfile(true) do
  source 'https://rubygems.org'

  gem 'activerecord', '5.1.0'
  gem 'mobility', '0.2.2'
  gem 'pg', '0.18.4'
  gem 'rspec', '3.6.0', require: 'rspec/autorun'
end

DB = ENV.fetch('MOBILITY_TEST_DB', 'mobility-test').freeze

ActiveRecord::Base.establish_connection(adapter: 'postgresql', database: DB)
ActiveRecord::Base.logger = Logger.new($stdout)

I18n.default_locale = :en
I18n.available_locales = %i[en ru]

Mobility.configure do |config|
  config.default_backend = :jsonb
  config.accessor_method = :translates
  config.default_fallbacks = { ru: :en, en: :ru }
end

ActiveRecord::Schema.define do
  create_table :articles, force: true do |t|
    t.jsonb :title, null: false, default: {}
  end
end

class Article < ActiveRecord::Base
  extend Mobility

  translates :title
end

RSpec.describe 'Fallbacks' do
  around do |example|
    I18n.with_locale(:en) { Article.create!(title: 'Title') }
    I18n.with_locale(:ru) { example.run }
  end

  subject(:article) { Article.last }

  it { expect(article.title).to eq('Title') }
end

I expected that setting default_fallbacks will make all translated attributes to fallback to existing locales by default. Moreover, when I try to use both default_fallbacks and the fallbacks option to translates, I get "default_fallbacks: undefined method call for {:ru=>:en, :en=>:ru}:Hash (NoMethodError)":

begin
  require 'bundler/inline'
rescue LoadError => e
  $stderr.puts 'Bundler version 1.10 or later is required.'
  raise e
end

gemfile(true) do
  source 'https://rubygems.org'

  gem 'activerecord', '5.1.0'
  gem 'mobility', '0.2.2'
  gem 'pg', '0.18.4'
end

DB = ENV.fetch('MOBILITY_TEST_DB', 'mobility-test').freeze

ActiveRecord::Base.establish_connection(adapter: 'postgresql', database: DB)
ActiveRecord::Base.logger = Logger.new($stdout)

I18n.default_locale = :en
I18n.available_locales = %i[en ru]

Mobility.configure do |config|
  config.default_backend = :jsonb
  config.accessor_method = :translates
  config.default_fallbacks = { ru: :en, en: :ru }
end

ActiveRecord::Schema.define do
  create_table :articles, force: true do |t|
    t.jsonb :title, null: false, default: {}
  end
end

class Article < ActiveRecord::Base
  extend Mobility

  translates :title, fallbacks: { ru: :en, en: :ru }
end

So I assume that either default_fallbacks is broken or the documentation is unclear on how to properly use it.

Context

  • ruby 2.4.1
  • active_record 5.1.0
  • mobility 0.2.2

Usage with rails_admin_globalize_field

Mobility's :table backend is very similar to globalize. globalize also has rails_admin_globalize_field gem which allows easy integration with rails_admin. The only thing that currently doesn't work is setting accepts_nested_attributes_for :translations, allow_destroy: true on the model that accepts translation (as instructed in usage). When set up, ActiveRecord complains about no association:

/home/quintasan/.rvm/gems/ruby-2.4.1@domore/gems/activerecord-5.1.3/lib/active_record/nested_attributes.rb:337:in `block in accepts_nested_attributes_for': No association found for name `translations'. Has it been defined yet? (ArgumentError)

Is there any way mobility can set this association when using :table backend or is there any way I can fool ActiveRecord into accepting that?

translated_attribute_names method accidentally removed from instance methods (extracted into plugin)

Context

The translated_attribute_names instance method was extracted into the attribute_methods plugin, although it is still defined on the class. It does not need to be in the plugin; if we were to extract it there, there should anyway be a deprecation phase.

Here is the line in the plugin.

@jmuheim reported this in #97.

Expected Behavior

If I call post.translated_attribute_names I should get the translated attribute names for the model (it should delegate to Post.translated_attribute_names.

Actual Behavior

I get a NoMethodError.

Possible Fix

Just move the delegate into Mobility::InstanceMethods.

Has_many association with the i18n scope return wrong records. Key value backend.

Example:

[53] ruby 2.4.1(main)> i = Dictionaries::Industry.find 2
[54] ruby 2.4.1(main)> i.categories
=> [#<Dictionaries::Category:0x007f44acaae4a0 id: 13, industry_id: 2, slug: "uplotnitelnaya-tehnika">,
 #<Dictionaries::Category:0x007f44acaaddc0 id: 16, industry_id: 2, slug: "dorozhnye-frezy">]
[55] ruby 2.4.1(main)> i.categories.i18n
=> [#<Dictionaries::Category:0x005560c2095a88 id: 13, industry_id: 2, slug: "uplotnitelnaya-tehnika">,
 #<Dictionaries::Category:0x005560c20952e0 id: 16, industry_id: 2, slug: "dorozhnye-frezy">]
[56] ruby 2.4.1(main)> 
[57] ruby 2.4.1(main)> 
[58] ruby 2.4.1(main)> i = Dictionaries::Industry.find 5
[59] ruby 2.4.1(main)> i.categories
=> [#<Dictionaries::Category:0x007f44ac7a5880 id: 36, industry_id: 5, slug: "cazhalki">,
 #<Dictionaries::Category:0x007f44ac7a56a0 id: 37, industry_id: 5, slug: "zhatki">,
 #<Dictionaries::Category:0x007f44ac79aea8 id: 55, industry_id: 5, slug: "uborochnaya-tehnika-dlya-sena-furazha">]
[60] ruby 2.4.1(main)> i.categories.i18n
=> [#<Dictionaries::Category:0x005560c2095a88 id: 13, industry_id: 2, slug: "uplotnitelnaya-tehnika">,
 #<Dictionaries::Category:0x005560c20952e0 id: 16, industry_id: 2, slug: "dorozhnye-frezy">]
[61] ruby 2.4.1(main)> 

Mobility stores `nil` instead of a JSONB object if the attribute is set via #write_attribute (JSONB backend).

Not really sure that it is a Mobility issue, it's more likely a compatibility bug with strip_attributes. If an attribute was previously set via #write_attribute (or the #[]= alias), when AR persists changes, this attribute is stored as nil (instead of a JSONB object { locale => attribute_value }).

Context

I use mobility and strip_attributes for my project. Recently I started to notice that sometimes when the user updates a record with columns backed by the Mobility's JSONB backend, some of these columns' values disappear (corresponding Postgres JSONB field is set to NULL, which results in losing all available translations for that field). After some debugging I found out that it happens when an attribute's value contains leading or trailing whitespace and the model's attributes are stripped via strip_attributes.

strip_attributes basically registers a before_validation hook that updates an attribute via #[]= if the stripped value differs from the original value, hence the reproducible example:

begin
  require 'bundler/inline'
rescue LoadError => e
  $stderr.puts 'Bundler version 1.10 or later is required.'
  raise e
end

gemfile(true) do
  source 'https://rubygems.org'

  gem 'activerecord', '5.1.0'
  gem 'mobility', '0.2.2'
  gem 'pg', '0.18.4'
  gem 'rspec', '3.6.0', require: 'rspec/autorun'
  gem 'pry-byebug'
end

DB = ENV.fetch('MOBILITY_TEST_DB', 'mobility-test').freeze

ActiveRecord::Base.establish_connection(adapter: 'postgresql', database: DB)
ActiveRecord::Base.logger = Logger.new($stdout)

I18n.locale = I18n.default_locale = :en
I18n.available_locales = %i[en ru]

Mobility.configure do |config|
  config.default_backend = :jsonb
  config.accessor_method = :translates
end

ActiveRecord::Schema.define do
  create_table :articles, force: true do |t|
    t.jsonb :title, default: {}
    t.jsonb :title_strip, default: {}
  end
end

class Article < ActiveRecord::Base
  extend Mobility

  translates :title, :title_strip

  STRIP_ATTRIBUTES = %i[title_strip].freeze

  before_validation do |record|
    record.attributes.slice(*STRIP_ATTRIBUTES.map(&:to_s)).each do |attr, value|
      next unless value.respond_to?(:strip)
      stripped_value = value.strip
      record[attr] = stripped_value if value != stripped_value
    end
  end
end

RSpec.describe Article do
  let(:params) { Hash[title: title, title_strip: title] }

  subject(:article) { Article.create!(params) }

  describe '#create!' do
    context "when title doesn't have leading or trailing space" do
      let(:title) { 'title without leading or trailing space' }

      it { expect(article.title).to eq(title) }
      it { expect(article.title_strip).to eq(title) }
    end

    context 'when title has leading space' do
      let(:title) { ' title with leading space' }

      it { expect(article.title).to eq(title) }
      it { expect(article.title_strip).to eq(title.strip) }
    end
  end
end

Expected Behavior

The tests should pass.

Actual Behavior

The last test fails.

Possible Fix

How I can mitigate the problem:

  • move input pre-processing out of the model;
  • blacklist Mobility-backed attributes for strip_attributes via the :except option (this is what I've done so far).

How it can be fixed:

  • make strip_attributes use attribute accessor methods for writing to a model (use record.public_send("#{attr}=", value) instead of record[attr] = value;
  • ???

Dup does not dup translations (KeyValue backend)

#84 fixed this issue for the Table backend, but it is still broken for the KeyValue backend.

Context

dup does not duplicate translations. Suppose we have:

post = Post.new
post.title = "foo"
post.save
post.title
#=> "foo"
post_dup = post.dup

Expected Behavior

The title of the dup'ed post should be "foo":

post_dup.title
#=> "foo"

Actual Behavior

The title is nil, because the translations are not dup'ed:

post_dup.title
#=> nil

There is already a spec for this which is disabled for KeyValue backends (AR + Sequel), so if someone works on this, just remove that skip code and make the test pass.

Pass fallback locale in getter

Accessing a specific language works already neatly with
post.title(locale: :en)
It would be great if one could simply pass a fallback locale in the getter directly
post.title(locale: :en, fallback: :de) #

Get a list of available locales

Is there a way to get a list of the locales in which a certain attribute has a value? I'm using the postgres jsonb backend, and I know I could just do @model.read_attribute(:title).keys, but that seems brittle and like there ought to be a way that doesn't require me to know about the json blob structure.

Avoid using scope i18n

Is it a way to use the i18n scope by default in every model that includes mobility?

Model.where(name: 'abc')

instead of

Model.i18n.where(name: 'abc')

Dirty problems using Sequel and Mobility

Returns an error if plugin: :dirty is loaded in Sequel even though its: dirty: true

Context

Using Sequel with jsonb and , dirty: true on the translates columns.

Expected Behavior

Well, it should work.

Actual Behavior

In sequel.rb:
Sequel::Model.plugin :dirty

In model:

  translates :title, dirty: true
  translates :synopsis, dirty: true

Returns (on Model.new):

2.3.3 :001 > a = Model.new
NoMethodError: undefined method `[]' for nil:NilClass
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/mobility-0.2.1/lib/mobility/backends/hash_valued.rb:14:in `read'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/mobility-0.2.1/lib/mobility/backend/stringify_locale.rb:10:in `read'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/mobility-0.2.1/lib/mobility/plugins/cache/translation_cacher.rb:23:in `block in initialize'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/mobility-0.2.1/lib/mobility/plugins/fallbacks.rb:99:in `block in define_read'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/mobility-0.2.1/lib/mobility/plugins/presence.rb:25:in `read'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/mobility-0.2.1/lib/mobility/plugins/default.rb:64:in `block in initialize'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/mobility-0.2.1/lib/mobility/attributes.rb:185:in `block in define_reader'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/sequel-4.49.0/lib/sequel/plugins/dirty.rb:194:in `change_column_value'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/sequel-4.49.0/lib/sequel/model/base.rb:1468:in `[]='
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/mobility-0.2.1/lib/mobility/backends/sequel/pg_hash.rb:32:in `block (4 levels) in <class:PgHash>'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/mobility-0.2.1/lib/mobility/backends/sequel/pg_hash.rb:32:in `each'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/mobility-0.2.1/lib/mobility/backends/sequel/pg_hash.rb:32:in `block (3 levels) in <class:PgHash>'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/sequel-4.49.0/lib/sequel/model/base.rb:1440:in `initialize'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/enumerize-2.1.2/lib/enumerize/base.rb:51:in `initialize'
	from (irb):1:in `new'
	from (irb):1
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/hanami-1.0.0/lib/hanami/commands/console.rb:61:in `start'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/hanami-1.0.0/lib/hanami/cli.rb:89:in `console'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/thor-0.20.0/lib/thor/command.rb:27:in `run'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/thor-0.20.0/lib/thor/invocation.rb:126:in `invoke_command'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/thor-0.20.0/lib/thor.rb:387:in `dispatch'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/thor-0.20.0/lib/thor/base.rb:466:in `start'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/gems/hanami-1.0.0/bin/hanami:5:in `<top (required)>'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/bin/hanami:22:in `load'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/bin/hanami:22:in `<main>'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/bin/ruby_executable_hooks:15:in `eval'
	from /home/vagrant/.rvm/gems/ruby-2.3.3/bin/ruby_executable_hooks:15:in `<main>'

Enumerize isn't the culprit, Mobility is. If I disable enumerize but keep Mobility it still fails, if I disable Dirty plugin it works, if I disable mobility but keep enumerize it works.

Possible Fix

None

translated_attribute_names method does not work for subclasses when using STI

Given the following scenario:

class Model
  include Mobility
  translates :title
end

class SubModel < Model
end

Model.translated_attribute_names => ["title"] 
SubModel.translated_attribute_names => [] 

P.S. Is there already a method to get an array with the i18n_accessors? like ["title_en", "title_es", "title_nl"] assuming those are the defined locales

fallback to whatever is left if the normal fallback results in a blank

I'm currently working with mobility and using two locales en and zh-CN but I have plans for about 50 or more locales to be added. Normally fallback would work like this zh-CN -> zh -> en, but in a case where there is only a field in zh-CN and the en one is blank how can I get this kind of behavior for 50 plus locales without defining fallback chains?

Thanks

Updating translation doesn't bust the cache_key

When you update a translation (table backend), the cache key is not being invalidated.

There's two approaches I see of fixing this:

Add touch: true when setting up the belongs_to relationship.

The advantage of this approach is it is quite simple to implement, and makes the model behave like any of the backends that store the translation within the model.

The disadvantage of this approach is that if someone is using the updated_at for something more than cache invalidation, this might not match their expectation.

Globalize has touch as an option. I've made a branch of mobility using this approach, which I'll use for now on my app.

Overwrite the cache_key

This is the approach that globalize takes.

One possible issue I see with their implementation is that it constructs the cache_key using only the translation based on the current locale. If you're only using methods like event.title, this works fine. But if you access a translation in a different locale than the one you're in (e.g., when the locale is :en, accessing event.title(:ja)), the cache_key will be incorrect.

More generally, I see this approach as potentially having performance implications, as now you need to load the translations to calculate the cache_key.

Value cached when accessor called with super: true

The value fetched with a translation reader is cached even when the super: true option is passed in.

Context

The super: true option allows you to bypass the Mobility accessor (reader or writer) and get or set using any original method defined with the same name.

The cache plugin caches all reads to the backend so that they do not need to be fetched for the same locale again while the model is loaded (and has not been reloaded/reset or saved).

Expected Behavior

post = Post.first
post.title(super: true)
#=> "foo"
# ^ This is the original value fo the `title` method
post.title
#=> "bar"
# ^ This is the value fetched from the backend

Actual Behavior

post = Post.first
post.title(super: true)
#=> "foo"
post.title
#=> "foo"

The second fetch without the super: true option returns the same value, because it has been cached. We now cannot get the real value from the backend unless we reload the model.

Possible Fix

Bypass cache in cache plugin when super: true is passed in as an option.

searches not working

Hi,
i was trying to make a search filed in my rails application but it seems not to work with the mobility tables.

This is my model function:
self.visible.where("LOWER(#{answer}) LIKE :term OR LOWER(#{question}) LIKE :term", term: "%#{term.downcase}%")

Rails console:

Faq Load (0.6ms)  SELECT "faqs".* FROM "faqs" WHERE "faqs"."visible" = $1 AND (LOWER(answer_nl) LIKE '%kaas%' OR LOWER(question_nl) LIKE '%kaas%')  [["visible", true]]
ActiveRecord::StatementInvalid: PG::UndefinedColumn: ERROR:  column "answer_nl" does not exist
LINE 1: ...ROM "faqs" WHERE "faqs"."visible" = $1 AND (LOWER(answer_nl)...

It looks like he is not searching in the mobility tables.
Am i doing something wrong or do i need an other way to approach this?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.