Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Disable fallbacks when using locale/fallthrough accessors #86

Merged
merged 4 commits into from Sep 13, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -222,7 +222,7 @@ locations, usually database columns. By default these values are stored as keys
tables, one for strings and one for text columns, but this can be easily
changed and/or customized (see the [Backends](#backends) section below).

### Getting and Setting Translations
### <a name="getset"></a> 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=`), but you may want to
@@ -309,6 +309,11 @@ 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](#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):
@@ -387,8 +392,8 @@ translated attributes on a class:
```ruby
class Word < ApplicationRecord
extend Mobility
translates :name, type: :string, fallbacks: { de: :ja, fr: :ja }
translates :meaning, type: :text, fallbacks: { de: :ja, fr: :ja }
translates :name, type: :string, fallbacks: { de: :ja, fr: :ja }, locale_accessors: true
translates :meaning, type: :text, fallbacks: { de: :ja, fr: :ja }, locale_accessors: true
end
```

@@ -404,17 +409,20 @@ but not for other locales:
```ruby
Mobility.locale = :ja
word = Word.create(name: "モビリティ", meaning: "(名詞):動きやすさ、可動性")
word.name(locale: :de)
Mobility.locale = :de
word.name
#=> "モビリティ"
word.meaning(locale: :de)
word.meaning
#=> "(名詞):動きやすさ、可動性"
word.name(locale: :fr)
Mobility.locale = :fr
word.name
#=> "モビリティ"
word.meaning(locale: :fr)
word.meaning
#=> "(名詞):動きやすさ、可動性"
word.name(locale: :ru)
Mobility.locale = :ru
word.name
#=> nil
word.meaning(locale: :ru)
word.meaning
#=> nil
```

@@ -423,11 +431,14 @@ You can optionally disable fallbacks to get the real value for a given locale
passing `fallback: false` (*singular*, not plural) to the getter method:

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

@@ -439,11 +450,28 @@ Mobility.with_locale(:fr) do
word.meaning = "(nf): aptitude à bouger, à se déplacer, à changer, à évoluer"
end
word.save
word.meaning(locale: :de, fallback: false)
Mobility.locale = :de
word.meaning(fallback: false)
#=> nil
word.meaning(locale: :de, fallback: :fr)
word.meaning(fallback: :fr)
#=> "(nf): aptitude à bouger, à se déplacer, à changer, à évoluer"
word.meaning(locale: :de, fallback: [:ja, :fr])
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](#getset) 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:

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

@@ -19,7 +19,7 @@ module Mobility

Attributes.new("title", backend: :my_backend, locale_accessors: [:en, :ja], cache: true, fallbacks: true)

will generate an anonymous module that behaves like this:
will generate an anonymous module that behaves (approximately) like this:

Module.new do
def title_backend
@@ -168,6 +168,17 @@ def each &block
names.each(&block)
end

# Process options passed into accessor method before calling backend, and
# return locale
# @param [Hash] options Options hash passed to accessor method
# @return [Symbol] locale
def self.process_options!(options)
(options[:locale] || Mobility.locale).tap { |locale|
Mobility.enforce_available_locales!(locale)
options[:locale] &&= !!locale
}.to_sym
end

private

def define_backend(attribute)
@@ -179,24 +190,24 @@ def define_backend(attribute)
end

def define_reader(attribute)
define_method attribute do |locale: Mobility.locale, **options|
define_method attribute do |**options|
return super() if options.delete(:super)
Mobility.enforce_available_locales!(locale)
mobility_backend_for(attribute).read(locale.to_sym, options)
locale = Mobility::Attributes.process_options!(options)
mobility_backend_for(attribute).read(locale, options)
end

define_method "#{attribute}?" do |locale: Mobility.locale, **options|
define_method "#{attribute}?" do |**options|
return super() if options.delete(:super)
Mobility.enforce_available_locales!(locale)
mobility_backend_for(attribute).present?(locale.to_sym, options)
locale = Mobility::Attributes.process_options!(options)
mobility_backend_for(attribute).present?(locale, options)
end
end

def define_writer(attribute)
define_method "#{attribute}=" do |value, locale: Mobility.locale, **options|
define_method "#{attribute}=" do |value, **options|
return super(value) if options.delete(:super)
Mobility.enforce_available_locales!(locale)
mobility_backend_for(attribute).write(locale.to_sym, value, options)
locale = Mobility::Attributes.process_options!(options)
mobility_backend_for(attribute).write(locale, value, options)
end
end

@@ -14,11 +14,7 @@ module Plugins
@note Dirty tracking can have unexpected results when combined with fallbacks.
A change in the fallback locale value will not mark an attribute falling
through to that locale as changed, even though it may look like it has
changed. However, when the value for the current locale is changed from nil
or blank to a new value, the change will be recorded as a change from that
fallback value, rather than from the nil or blank value. The specs are the
most reliable source of information on the interaction between dirty tracking
and fallbacks.
changed. See the specs for details on expected behavior.

=end
module Dirty
@@ -15,14 +15,20 @@ module Plugins
If a hash is passed to the +fallbacks+ option, a new fallbacks instance will be
created for the model with the hash defining additional fallbacks.

In addition, fallbacks can be disabled when reading by passing <tt>fallback:
false</tt> to the reader method. This can be useful to determine the actual
value of the translated attribute, including a possible +nil+ value. You can
also pass a locale or array of locales to the +fallback+ option to use that
locale or locales that read, e.g. <tt>fallback: :fr</tt> would fetch the French
translation if the value in the current locale was +nil+, whereas <tt>fallback:
[:fr, :es]</tt> would try French, then Spanish if the value in the current
locale was +nil+.
In addition, fallbacks are disabled in certain situation. To explicitly disable
fallbacks when reading and writing, you can pass the <tt>fallback: false</tt>
option to the reader method. This can be useful to determine the actual
value of the translated attribute, including a possible +nil+ value.

The other situation where fallbacks are disabled is when the locale is
specified explicitly, either by passing a `locale` option to the accessor or by
using locale or fallthrough accessors. (See example below.)

You can also pass a locale or array of locales to the +fallback+ option to use
that locale or locales that read, e.g. <tt>fallback: :fr</tt> would fetch the
French translation if the value in the current locale was +nil+, whereas
<tt>fallback: [:fr, :es]</tt> would try French, then Spanish if the value in
the current locale was +nil+.

@see https://github.com/svenfuchs/i18n/wiki/Fallbacks I18n Fallbacks

@@ -76,6 +82,26 @@ class Post
#=> nil
post.title(fallback: :fr)
#=> "Mobilité"

@example Fallbacks disabled
class Post
translates :title, fallbacks: { :'fr' => 'en' }, locale_accessors: true
end

I18n.default_locale = :en
Mobility.locale = :en
post = Post.new(title: "Mobility")

Mobility.locale = :fr
post.title
#=> "Mobility"
post.title(fallback: false)
#=> nil
post.title(locale: :fr)
#=> nil
post.title_fr
#=> nil

=end
class Fallbacks < Module
# Applies fallbacks plugin to attributes.
@@ -93,14 +119,15 @@ def initialize(fallbacks_option)

def define_read(fallbacks)
define_method :read do |locale, fallback: true, **options|
return super(locale, options) if !fallback
return super(locale, options) if fallbacks.nil? && fallback == true
return super(locale, options) if !fallback || options[:locale]

locales = fallback == true ? fallbacks[locale] : [locale, *fallback]
locales.detect do |fallback_locale|
locales.each do |fallback_locale|
value = super(fallback_locale, options)
break value if Util.present?(value)
return value if Util.present?(value)
end

super(locale, options)
end
end

@@ -110,7 +137,7 @@ def convert_option_to_fallbacks(option)
elsif option == true
Mobility.default_fallbacks
else
nil
Hash.new { [] }
end
end
end
@@ -46,12 +46,12 @@ def self.apply(attributes, option)
def initialize(*attributes)
method_name_regex = /\A(#{attributes.join('|'.freeze)})_([a-z]{2}(_[a-z]{2})?)(=?|\??)\z/.freeze

define_method :method_missing do |method_name, *arguments, &block|
define_method :method_missing do |method_name, *arguments, **options, &block|
if method_name =~ method_name_regex
attribute = $1.to_sym
locale, suffix = $2.split('_'.freeze)
locale = "#{locale}-#{suffix.upcase}".freeze if suffix
Mobility.with_locale(locale) { public_send("#{attribute}#{$4}".freeze, *arguments) }
public_send("#{attribute}#{$4}".freeze, *arguments, **options, locale: locale.to_sym)
else
super(method_name, *arguments, &block)
end
@@ -58,14 +58,14 @@ def define_reader(name, locale)

define_method "#{name}_#{normalized_locale}" do |**options|
return super() if options.delete(:super)
warn warning_message if options.delete(:locale)
Mobility.with_locale(locale) { send(name, options) }
warn warning_message if options[:locale]
send(name, **options, locale: locale)
end

define_method "#{name}_#{normalized_locale}?" do |**options|
return super() if options.delete(:super)
warn warning_message if options.delete(:locale)
Mobility.with_locale(locale) { send("#{name}?", options) }
warn warning_message if options[:locale]
send("#{name}?", **options, locale: locale)
end
end

@@ -75,8 +75,8 @@ def define_writer(name, locale)

define_method "#{name}_#{normalized_locale}=" do |value, **options|
return super(value) if options.delete(:super)
warn warning_message if options.delete(:locale)
Mobility.with_locale(locale) { send("#{name}=", value, options) }
warn warning_message if options[:locale]
send("#{name}=", value, **options, locale: locale)
end
end
end
@@ -77,7 +77,7 @@
describe "fallbacks" do
let!(:post) { FallbackPost.create(title: "foo title") }

it "falls through to default locale" do
it "falls through to default locale with no options" do
Mobility.locale = :ja
expect(post.title).to eq("foo title")
end
@@ -86,5 +86,15 @@
Mobility.locale = :ja
expect(post.title(fallback: false)).to eq(nil)
end

it "does not fall through to default locale when locale is set explicitly" do
Mobility.locale = :en
expect(post.title(locale: :ja)).to eq(nil)
end

it "does not fall through to default locale when locale accessor is used" do
Mobility.locale = :en
expect(post.title_ja).to eq(nil)
end
end
end
@@ -132,8 +132,8 @@
expect(article.title?).to eq(true)
end

it "correctly maps locale through getter options" do
expect(backend).to receive(:read).with(:fr, {}).and_return("foo")
it "correctly maps locale through getter options and converts to boolean" do
expect(backend).to receive(:read).with(:fr, locale: true).and_return("foo")
expect(article.title(locale: "fr")).to eq("foo")
end

@@ -284,7 +284,7 @@ def save
expect(article.title).to eq("Title")
expect(article.changed?).to eq(true)
expect(article.changed).to match_array(["title_ja", "title_en"])
expect(article.changes).to eq({ "title_ja" => [nil, "ばばば"], "title_en" => ["ばばば", "Title"]})
expect(article.changes).to eq({ "title_ja" => [nil, "ばばば"], "title_en" => [nil, "Title"]})
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a byproduct, this change also makes dirty/fallbacks compatibility a bit less weird.

end
end
end
@@ -16,10 +16,6 @@ def title=(_value, **_); end
it_behaves_like "locale accessor", :title, :en
it_behaves_like "locale accessor", :title, :de
it_behaves_like "locale accessor", :title, :'pt-BR'

it "raises InvalidLocale if locale is not in I18n.available_locales" do
expect { model_class.new.title_ru }.to raise_error(Mobility::InvalidLocale)
end
end

describe ".apply" do
@@ -40,11 +40,11 @@ def title=(_value, **_); end
model_class.include(described_class.new(:title))
instance = model_class.new
warning_message = /locale passed as option to locale accessor will be ignored/
expect(instance).to receive(:title).and_return("foo")
expect(instance).to receive(:title).with(locale: :pt).and_return("foo")
expect { expect(instance.title_pt(locale: :en)).to eq("foo") }.to output(warning_message).to_stderr
expect(instance).to receive(:title?).and_return(true)
expect(instance).to receive(:title?).with(locale: :pt).and_return(true)
expect { expect(instance.title_pt?(locale: :en)).to eq(true) }.to output(warning_message).to_stderr
expect(instance).to receive(:title=).with("new foo", {})
expect(instance).to receive(:title=).with("new foo", locale: :pt)
expect { instance.send(:title_pt=, "new foo", locale: :en)}.to output(warning_message).to_stderr
end
end
@@ -201,9 +201,9 @@ def values
article.title = "Title"
expect(article.title).to eq("Title")
expect(article.column_changed?(:title)).to eq(true)
expect(article.column_change(:title)).to eq(["ばばば", "Title"])
expect(article.column_change(:title)).to eq([nil, "Title"])
expect(article.changed_columns).to match_array([:title_ja, :title_en])
expect(article.column_changes).to eq({ title_ja: [nil, "ばばば"], title_en: ["ばばば", "Title"]})
expect(article.column_changes).to eq({ title_ja: [nil, "ばばば"], title_en: [nil, "Title"]})
end
end
end