diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4015f114..f852fc164 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,15 @@ on: push: branches: - master + - 1-2 + - 1-1 - 1-0 - 0-8 pull_request: branches: - master + - 1-2 + - 1-1 - 1-0 - 0-8 @@ -21,6 +25,7 @@ jobs: fail-fast: false matrix: ruby: + - '3.0' - '2.7' - '2.6' database: @@ -40,11 +45,17 @@ jobs: version: '6.0' - name: 'active_record' version: '6.1' + - name: 'active_record' + version: '7.0' - name: 'sequel' version: '5' experimental: [false] feature: ['unit'] include: + - ruby: '3.0' + feature: 'unit' + orm: + experimental: false - ruby: '2.7' feature: 'unit' orm: @@ -53,15 +64,45 @@ jobs: feature: 'unit' orm: experimental: false - - ruby: '2.5' - feature: 'unit' + - ruby: '3.0' + feature: 'rails' orm: + name: 'active_record' + version: '7.0' + database: 'sqlite3' experimental: false + - ruby: '3.0' + feature: 'performance' + experimental: false + - ruby: '3.0' + feature: 'i18n_fallbacks' + experimental: false + - ruby: '3.0' + database: 'sqlite3' + feature: 'unit' + orm: + name: 'active_record' + version: 'edge' + experimental: true + - ruby: '3.0' + database: 'mysql' + feature: 'unit' + orm: + name: 'active_record' + version: 'edge' + experimental: true + - ruby: '3.0' + database: 'postgres' + feature: 'unit' + orm: + name: 'active_record' + version: 'edge' + experimental: true - ruby: '2.7' feature: 'rails' orm: name: 'active_record' - version: '6.1' + version: '7.0' database: 'sqlite3' experimental: false - ruby: '2.7' @@ -75,27 +116,47 @@ jobs: feature: 'unit' orm: name: 'active_record' - version: '7.0' + version: 'edge' experimental: true - ruby: '2.7' database: 'mysql' feature: 'unit' orm: name: 'active_record' - version: '7.0' + version: 'edge' experimental: true - ruby: '2.7' database: 'postgres' feature: 'unit' orm: name: 'active_record' - version: '7.0' + version: 'edge' experimental: true exclude: + - ruby: '2.6' + orm: + name: 'active_record' + version: '7.0' - ruby: '2.7' orm: name: 'active_record' version: '4.2' + - ruby: '3.0' + orm: + name: 'active_record' + version: '4.2' + - ruby: '3.0' + orm: + name: 'active_record' + version: '5.0' + - ruby: '3.0' + orm: + name: 'active_record' + version: '5.1' + - ruby: '3.0' + orm: + name: 'active_record' + version: '5.2' env: DB: ${{ matrix.database }} BUNDLE_JOBS: 4 @@ -133,9 +194,6 @@ jobs: - name: Install Postgres run: sudo apt-get install libpq-dev postgresql-client -y if: matrix.database == 'postgres' - - name: Install MySQL - run: sudo apt-get install libmysqlclient-dev mysql-client -y - if: matrix.database == 'mysql' - id: cache-bundler uses: actions/cache@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d26be5a3..ca3cb5593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,84 @@ # Mobility Changelog ## Unreleased -- Assign blank values in pg hash backends - ([#516](https://github.com/shioyama/mobility/pull/516)) + +- Fix ActiveRecord JSONB blank values + ([#536](https://github.com/shioyama/mobility/pull/536)) +- Support primary keys other then :id on model classes + ([#542](https://github.com/shioyama/mobility/pull/542)) +- Clean up and refactor container backend + ([#543](https://github.com/shioyama/mobility/pull/543)), + thanks [doits](https://github.com/doits)! +- Update fallthrough_accessor regex to allow for ISO 639-2 codes + ([#580](https://github.com/shioyama/mobility/pull/580)) + thanks [phil-allcock](https://github.com/phil-allcock)! + +## 1.2 + +### 1.2.9 +- Fix missing method `mobility_attribute?` on Rails 7 + ([#582](https://github.com/shioyama/mobility/pull/582) and + [#584](https://github.com/shioyama/mobility/pull/584)) + +### 1.2.8 +- Fix issues with subclassing, such as when using AR STI, + fixes [#566](https://github.com/shioyama/mobility/issues/566) + ([#568](https://github.com/shioyama/mobility/pull/568)) +- Handle `attribute_method_matchers` rename (part of + [#560](https://github.com/shioyama/mobility/pull/560)) + +### 1.2.7 +- Do not query same attribute more than once, fixes + [#564](https://github.com/shioyama/mobility/pull/578) + ([#577](https://github.com/shioyama/mobility/pull/577)) + +### 1.2.6 +- Require mfa on rubygems + ([#545](https://github.com/shioyama/mobility/pull/545)) + +### 1.2.5 +- Avoid referencing ActiveRecord::Base + ([#550](https://github.com/shioyama/mobility/pull/550)) + +### 1.2.4 +- Fix fallbacks performance regression + ([#548](https://github.com/shioyama/mobility/pull/548)) + +### 1.2.3 +- Fix passing wrong options to super in fallbacks plugin + ([#539](https://github.com/shioyama/mobility/pull/539)) + +### 1.2.2 +- Make models work with `Marshal.dump` + ([#532](https://github.com/shioyama/mobility/pull/532)) +- Fix Sequel container op in Sequel + ([#533](https://github.com/shioyama/mobility/pull/533)) +- Simplify Fallbacks plugin + ([#531](https://github.com/shioyama/mobility/pull/531)) + +### 1.2.1 +- Refactor ColumnFallback plugin + ([#530](https://github.com/shioyama/mobility/pull/530)) + +### 1.2.0 +- Add ColumnFallback plugin + ([#512](https://github.com/shioyama/mobility/pull/512)) +- Fix Sequel querying on untranslated attributes in `i18n` block + ([#529](https://github.com/shioyama/mobility/pull/529)) +- Allow passing configured backend class as third argument to setup + ([#528](https://github.com/shioyama/mobility/pull/528)) +- Clearly distinguish backend classes from their configured subclasses + ([#527](https://github.com/shioyama/mobility/pull/527)) ## 1.1 +### 1.1.3 +- Do not swallow keyword args on ruby 3 in fallthrough accessors + ([#520](https://github.com/shioyama/mobility/pull/520)) thanks + [doits](https://github.com/doits)! +- Assign blank values in pg hash backends + ([#516](https://github.com/shioyama/mobility/pull/516)) + ### 1.1.2 - Check whether class responds to mobility_attribute? ([#515](https://github.com/shioyama/mobility/pull/515)) diff --git a/Gemfile b/Gemfile index cac693c84..68f2ded9b 100644 --- a/Gemfile +++ b/Gemfile @@ -8,11 +8,11 @@ orm, orm_version = ENV['ORM'], ENV['ORM_VERSION'] group :development, :test do case orm when 'active_record' - orm_version ||= '6.1' + orm_version ||= '7.0' case orm_version - when '4.2', '5.0', '5.1', '5.2', '6.0', '6.1' + when '4.2', '5.0', '5.1', '5.2', '6.0', '6.1', '7.0' gem 'activerecord', "~> #{orm_version}.0" - when '7.0' + when 'edge' git 'https://github.com/rails/rails.git', branch: 'main' do gem 'activerecord' gem 'activesupport' diff --git a/README.md b/README.md index f826d1b2c..f97f1b0ed 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Installation Add this line to your application's Gemfile: ```ruby -gem 'mobility', '~> 1.1.2' +gem 'mobility', '~> 1.2.9' ``` ### ActiveRecord (Rails) diff --git a/certs/shioyama.pem b/certs/shioyama.pem index cc022e53e..b17aca9fa 100644 --- a/certs/shioyama.pem +++ b/certs/shioyama.pem @@ -1,7 +1,7 @@ -----BEGIN CERTIFICATE----- MIIEODCCAqCgAwIBAgIBATANBgkqhkiG9w0BAQsFADAjMSEwHwYDVQQDDBhjaHJp -cy9EQz1kZWppbWF0YS9EQz1jb20wHhcNMjEwMjE4MTMxMzA5WhcNMjIwMjE4MTMx -MzA5WjAjMSEwHwYDVQQDDBhjaHJpcy9EQz1kZWppbWF0YS9EQz1jb20wggGiMA0G +cy9EQz1kZWppbWF0YS9EQz1jb20wHhcNMjIwMzAyMDMyMTA5WhcNMjMwMzAyMDMy +MTA5WjAjMSEwHwYDVQQDDBhjaHJpcy9EQz1kZWppbWF0YS9EQz1jb20wggGiMA0G CSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQDURCKbt5oY0sCp4kYK1u5SLzVHg6Q1 2LejeQvUGpR3gulWqrq/507XRxE/9FSpLfgo3cGGYio1/gg2Yp7pBI4ZNEz8d2Vg 6caWLHYtHYF0/jlo177UspEF1bt3lCCmaA/ZyQpvoLi76Jf6VCBjepMqhLjeBSsA @@ -13,13 +13,13 @@ bRGON33BZ99gPiYdGfd3Pc/7FooteJASjKIO4Hman2ELRIdu6Bq+fIkTdJBcruS/ XL6xoRitCG7CX0IqmMKuLiKA/J0amAikHGsCAwEAAaN3MHUwCQYDVR0TBAIwADAL BgNVHQ8EBAMCBLAwHQYDVR0OBBYEFMNUGAhS68egZT6DOfJwrfIdCtT/MB0GA1Ud EQQWMBSBEmNocmlzQGRlamltYXRhLmNvbTAdBgNVHRIEFjAUgRJjaHJpc0BkZWpp -bWF0YS5jb20wDQYJKoZIhvcNAQELBQADggGBAH1RnWhD9bum/ijqzAAlkGWYzGza -h/3seA2bg1r5bbttFjD48f7RepfoAMxAqfiUWcukoukJeu7UY8jWmUIn9ut1oXct -Fh0YnueLFzzmppCCU+/SX5mc1y7mHYZHiU5n8qy1wJ6ljLWXVeprgJ96NdnmxuVU -dzPPSDTex/x7xBvHiaPc/uZSLc173N3qdY/Cd0B3/OflYeU2h5UpIHnmXrsONdMC -Xohy+Rrr2yT09MPYG+llpLHDnXmTnPsOZUSL5Q4c/iolodv4xJZKwLMZwrm2hQl9 -9Or9Os+qxY0zWxmWuAtTFrskLAMhckCPDEcqSZmW4CT1a/quC2Oh0y1GsXPcqtqt -hLRuwfTXGor6bg4CrU7GRbSqjvnBepct5lwZiZrOCnMEUpY+9Q8fwmG3o3B+wBsw -eBMcZq0d1tbtv1M1UXND9mOfhLZ31YvoSTPkrJiRpljUNgD0+ugelnr1/5X/9k8y -J9QOd3C5jpSShf/HMvpJnFuSYFm19cH9GrHjvw== +bWF0YS5jb20wDQYJKoZIhvcNAQELBQADggGBAHJPxoU7brN6goci9iclRYUq1Prs +51E87VRywUDysHpaHJoGRTqRJsQxi5aGZ9pIbiXGj9WJKKnrhiv5cM5fWtAsz564 +Ro+Zyx6UVt/2z9rcfYrnXtmC9wh+5/0UqAeoan9RiSd8lscQZ9pEY0E3cmzJRHSU +t8kwB2ipVkFO17mdTVgc3C2ZbWRq80eTzkELDBb+8xO0Cskyh4sGMTOKfHs2RWcJ +217Qeg0F9w0RcqwnORe5zmPihY9zswCPh0IUaJa1pNY+MLTff9LE/qTl3WVTgrif +HsSSnstQYPSLJ3hSP/cu1aOmdXlJiim//XlDQ9DNp4KWje3UW3DMRdTwjW5wPmUq +VA9Ij7DUPaZzUpy1NZEigf1GshvslOnvN5bgol1YFR46jpfZVlgt0K5XBQVNvp/A +QHgocnSksU5GOM+G2UhjVycbTamd+bCxjWAZTEDZNafFt5CmnfK1D1UTIblR/ci9 +fUDdW+GhxhobB8N1mtDRlhELoxLLjx7mSvJ3Wg== -----END CERTIFICATE----- diff --git a/lib/mobility.rb b/lib/mobility.rb index 8bdf50e73..dd3f9d059 100644 --- a/lib/mobility.rb +++ b/lib/mobility.rb @@ -73,6 +73,9 @@ class Comment plugin enabled. =end + +def ruby2_keywords(*); end unless respond_to?(:ruby2_keywords, true) + module Mobility # A generic exception used by Mobility. class Error < StandardError diff --git a/lib/mobility/backend.rb b/lib/mobility/backend.rb index 755431712..3f8f66ccd 100644 --- a/lib/mobility/backend.rb +++ b/lib/mobility/backend.rb @@ -23,8 +23,8 @@ module Mobility corresponding to valid keys for configuring this backend. - implement a +configure+ class method to apply any normalization to the keys on the options hash included in +valid_keys+ -- call the +setup+ method yielding attributes and options to configure the - model class +- call the +setup+ method yielding attributes and options (and optionally the + configured backend class) to configure the model class @example Defining a Backend class MyBackend @@ -49,6 +49,13 @@ def self.configure(options) setup do |attributes, options| # Do something with attributes and options in context of model class. end + + # The block can optionally take the configured backend class as its third + # argument: + # + # setup do |attributes, options, backend_class| + # ... + # end end @see Mobility::Translations @@ -147,9 +154,11 @@ def valid_keys def setup &block if @setup_block setup_block = @setup_block - @setup_block = lambda do |*args| - class_exec(*args, &setup_block) - class_exec(*args, &block) + exec_setup_block = method(:exec_setup_block) + @setup_block = lambda do |attributes, options, backend_class| + [setup_block, block].each do |blk| + exec_setup_block.call(self, attributes, options, backend_class, &blk) + end end else @setup_block = block @@ -158,17 +167,6 @@ def setup &block def inherited(subclass) subclass.instance_variable_set(:@setup_block, @setup_block) - subclass.instance_variable_set(:@options, @options) - subclass.instance_variable_set(:@model_class, @model_class) - end - - # Call setup block on a class with attributes and options. - # @param model_class Class to be setup-ed - # @param [Array] attribute_names - # @param [Hash] options - def setup_model(model_class, attribute_names) - return unless setup_block = @setup_block - model_class.class_exec(attribute_names, options, &setup_block) end # Build a subclass of this backend class for a given set of options @@ -204,10 +202,30 @@ def #{name} EOM end - # Show useful information about this backend class, if it has no name. - # @return [String] - def inspect - name ? super : "#<#{superclass.name}>" + def options + raise_unconfigured!(:options) + end + + def model_class + raise_unconfigured!(:model_class) + end + + def setup_model(_model_class, _attributes) + raise_unconfigured!(:setup_model) + end + + private + + def raise_unconfigured!(method_name) + raise UnconfiguredError, "You are calling #{method_name} on an unconfigured backend class." + end + + def exec_setup_block(model_class, *args, &block) + if block.arity == 3 + model_class.class_exec(*args[0..2], &block) + else + model_class.class_exec(*args[0..1], &block) + end end end diff --git a/lib/mobility/backends/active_record/container.rb b/lib/mobility/backends/active_record/container.rb index 079790717..83ca63984 100644 --- a/lib/mobility/backends/active_record/container.rb +++ b/lib/mobility/backends/active_record/container.rb @@ -22,7 +22,11 @@ class ActiveRecord::Container # @param [Hash] options # @return [String,Integer,Boolean] Value of translation def read(locale, _ = nil) - model_translations(locale)[attribute] + locale_translations = model_translations(locale) + + return unless locale_translations + + locale_translations[attribute.to_s] end # @note Translation may be a string, integer, boolean, hash or array @@ -33,7 +37,7 @@ def read(locale, _ = nil) # @return [String,Integer,Boolean] Updated value def write(locale, value, _ = nil) set_attribute_translation(locale, value) - model_translations(locale)[attribute] + read(locale) end # @!endgroup @@ -42,8 +46,7 @@ class << self # @option options [Symbol] column_name (:translations) Name of column on which to store translations # @raise [InvalidColumnType] if the type of the container column is not json or jsonb def configure(options) - options[:column_name] ||= :translations - options[:column_name] = options[:column_name].to_sym + options[:column_name] = options[:column_name]&.to_sym || :translations end # @!endgroup @@ -78,14 +81,12 @@ def get_column_type # @!macro backend_iterator def each_locale - model[column_name].each do |l, v| - yield l.to_sym if v.present? + model[column_name].each_key do |l| + yield l.to_sym end end setup do |_attributes, options| - store options[:column_name], coder: Coder - # Fix for duping depth-2 jsonb column in AR < 5.0 if ::ActiveRecord::VERSION::STRING < '5.0' column_name = options[:column_name] @@ -107,32 +108,23 @@ def initialize_dup(source) private def model_translations(locale) - model[column_name][locale] ||= {} + model[column_name][locale.to_s] end def set_attribute_translation(locale, value) - translations = model[column_name] || {} - translations[locale.to_s] ||= {} - translations[locale.to_s][attribute] = value - model[column_name] = translations - end + locale_translations = model_translations(locale) - class Coder - def self.dump(obj) - if obj.is_a? ::Hash - obj.inject({}) do |translations, (locale, value)| - value.each do |k, v| - (translations[locale] ||= {})[k] = v if v.present? - end - translations - end + if locale_translations + if value.nil? + locale_translations.delete(attribute.to_s) + + # delete empty locale hash if last attribute was just deleted + model[column_name].delete(locale.to_s) if locale_translations.empty? else - raise ArgumentError, "Attribute is supposed to be a Hash, but was a #{obj.class}. -- #{obj.inspect}" + locale_translations[attribute.to_s] = value end - end - - def self.load(obj) - obj + elsif !value.nil? + model[column_name][locale.to_s] = { attribute.to_s => value } end end diff --git a/lib/mobility/backends/active_record/key_value.rb b/lib/mobility/backends/active_record/key_value.rb index de4c9d5b9..f2ac12c3e 100644 --- a/lib/mobility/backends/active_record/key_value.rb +++ b/lib/mobility/backends/active_record/key_value.rb @@ -65,6 +65,69 @@ def apply_scope(relation, predicate, locale = Mobility.locale, invert: false) end end + # Called from setup block. Can be overridden to customize behaviour. + def define_has_many_association(klass, attributes) + # Track all attributes for this association, so that we can limit the scope + # of keys for the association to only these attributes. We need to track the + # attributes assigned to the association in case this setup code is called + # multiple times, so we don't "forget" earlier attributes. + # + attrs_method_name = :"__#{association_name}_attributes" + association_attributes = (klass.instance_variable_get(:"@#{attrs_method_name}") || []) + attributes + klass.instance_variable_set(:"@#{attrs_method_name}", association_attributes) + + b = self + + klass.has_many association_name, ->{ where b.key_column => association_attributes }, + as: belongs_to, + class_name: class_name.name, + inverse_of: belongs_to, + autosave: true + end + + # Called from setup block. Can be overridden to customize behaviour. + def define_initialize_dup(klass) + b = self + module_name = "MobilityArKeyValue#{association_name.to_s.camelcase}" + unless const_defined?(module_name) + callback_methods = Module.new do + define_method :initialize_dup do |source| + super(source) + self.send("#{b.association_name}=", source.send(b.association_name).map(&:dup)) + # Set inverse on associations + send(b.association_name).each do |translation| + translation.send(:"#{b.belongs_to}=", self) + end + end + end + klass.include const_set(module_name, callback_methods) + end + end + + # Called from setup block. Can be overridden to customize behaviour. + def define_before_save_callback(klass) + b = self + klass.before_save do + send(b.association_name).select { |t| t.send(b.value_column).blank? }.each do |translation| + send(b.association_name).destroy(translation) + end + end + end + + # Called from setup block. Can be overridden to customize behaviour. + def define_after_destroy_callback(klass) + # Ensure we only call after destroy hook once per translations class + b = self + translation_classes = [class_name, *Mobility::Backends::ActiveRecord::KeyValue::Translation.descendants].uniq + klass.after_destroy do + @mobility_after_destroy_translation_classes = [] unless defined?(@mobility_after_destroy_translation_classes) + (translation_classes - @mobility_after_destroy_translation_classes).each do |translation_class| + translation_class.where(b.belongs_to => self).destroy_all + end + @mobility_after_destroy_translation_classes += translation_classes + end + end + private def join_translations(relation, key, locale, join_type) @@ -75,7 +138,7 @@ def join_translations(relation, key, locale, join_type) on(t[key_column].eq(key). and(t[:locale].eq(locale). and(t[:"#{belongs_to}_type"].eq(model_class.base_class.name). - and(t[:"#{belongs_to}_id"].eq(m[:id]))))).join_sources) + and(t[:"#{belongs_to}_id"].eq(m[model_class.primary_key] || m[:id]))))).join_sources) end def already_joined?(relation, name, locale, join_type) diff --git a/lib/mobility/backends/active_record/pg_hash.rb b/lib/mobility/backends/active_record/pg_hash.rb index 381a86a3e..3fcb7c136 100644 --- a/lib/mobility/backends/active_record/pg_hash.rb +++ b/lib/mobility/backends/active_record/pg_hash.rb @@ -15,34 +15,25 @@ class PgHash include ActiveRecord include HashValued - # @!macro backend_iterator - def each_locale - super { |l| yield l.to_sym } + def read(locale, _options = nil) + translations[locale.to_s] end - def translations - model.read_attribute(column_name) + def write(locale, value, _options = nil) + if value.nil? + translations.delete(locale.to_s) + else + translations[locale.to_s] = value + end end - setup do |attributes, options = {}| - attributes.each { |attribute| store (options[:column_affix] % attribute), coder: Coder } + # @!macro backend_iterator + def each_locale + super { |l| yield l.to_sym } end - class Coder - def self.dump(obj) - if obj.is_a? ::Hash - obj.inject({}) do |translations, (locale, value)| - translations[locale] = value unless value.nil? - translations - end - else - raise ArgumentError, "Attribute is supposed to be a Hash, but was a #{obj.class}. -- #{obj.inspect}" - end - end - - def self.load(obj) - obj - end + def translations + model[column_name] end end private_constant :PgHash diff --git a/lib/mobility/backends/active_record/table.rb b/lib/mobility/backends/active_record/table.rb index 6148b10d7..195625a4b 100644 --- a/lib/mobility/backends/active_record/table.rb +++ b/lib/mobility/backends/active_record/table.rb @@ -145,7 +145,7 @@ def join_translations(relation, locale, join_type) m = model_class.arel_table t = model_class.const_get(subclass_name).arel_table.alias(table_alias(locale)) relation.joins(m.join(t, join_type). - on(t[foreign_key].eq(m[:id]). + on(t[foreign_key].eq(m[model_class.primary_key] || m[:id]). and(t[:locale].eq(locale))).join_sources) end diff --git a/lib/mobility/backends/sequel/container.rb b/lib/mobility/backends/sequel/container.rb index 73fcfb583..ac3fb454b 100644 --- a/lib/mobility/backends/sequel/container.rb +++ b/lib/mobility/backends/sequel/container.rb @@ -57,9 +57,7 @@ def each_locale end end - backend = self - - setup do |attributes, options| + setup do |attributes, options, backend_class| column_name = options[:column_name] mod = Module.new do define_method :before_validation do @@ -71,7 +69,7 @@ def each_locale end end include mod - backend.define_hash_initializer(mod, [column_name]) + backend_class.define_hash_initializer(mod, [column_name]) plugin :defaults_setter attributes.each { |attribute| default_values[attribute.to_sym] = {} } @@ -102,7 +100,7 @@ class InvalidColumnType < StandardError; end # @return [Mobility::Backends::Sequel::Container::JSONOp,Mobility::Backends::Sequel::Container::JSONBOp] def self.build_op(attr, locale) klass = const_get("#{options[:column_type].upcase}Op") - klass.new(klass.new(column_name.to_sym)[locale.to_s]).get_text(attr) + klass.new(klass.new(column_name.to_sym).get(locale.to_s)).get_text(attr) end class JSONOp < ::Sequel::Postgres::JSONOp; end diff --git a/lib/mobility/backends/sequel/key_value.rb b/lib/mobility/backends/sequel/key_value.rb index e47875af9..3c805fbaa 100644 --- a/lib/mobility/backends/sequel/key_value.rb +++ b/lib/mobility/backends/sequel/key_value.rb @@ -51,6 +51,63 @@ def prepare_dataset(dataset, predicate, locale) end end + # Called from setup block. Can be overridden to customize behaviour. + def define_one_to_many_association(klass, attributes) + belongs_to_id = :"#{belongs_to}_id" + belongs_to_type = :"#{belongs_to}_type" + + # Track all attributes for this association, so that we can limit the scope + # of keys for the association to only these attributes. We need to track the + # attributes assigned to the association in case this setup code is called + # multiple times, so we don't "forget" earlier attributes. + # + attrs_method_name = :"#{association_name}_attributes" + association_attributes = (klass.instance_variable_get(:"@#{attrs_method_name}") || []) + attributes + klass.instance_variable_set(:"@#{attrs_method_name}", association_attributes) + + klass.one_to_many association_name, + reciprocal: belongs_to, + key: belongs_to_id, + reciprocal_type: :one_to_many, + conditions: { belongs_to_type => klass.to_s, key_column => association_attributes }, + adder: proc { |translation| translation.update(belongs_to_id => pk, belongs_to_type => self.class.to_s) }, + remover: proc { |translation| translation.update(belongs_to_id => nil, belongs_to_type => nil) }, + clearer: proc { send_(:"#{association_name}_dataset").update(belongs_to_id => nil, belongs_to_type => nil) }, + class: class_name + end + + # Called from setup block. Can be overridden to customize behaviour. + def define_save_callbacks(klass, attributes) + b = self + callback_methods = Module.new do + define_method :before_save do + super() + send(b.association_name).select { |t| attributes.include?(t.__send__(b.key_column)) && Util.blank?(t.__send__(b.value_column)) }.each(&:destroy) + end + define_method :after_save do + super() + attributes.each { |attribute| mobility_backends[attribute].save_translations } + end + end + klass.include callback_methods + end + + # Called from setup block. Can be overridden to customize behaviour. + def define_after_destroy_callback(klass) + # Clean up *all* leftover translations of this model, only once. + b = self + translation_classes = [class_name, *Mobility::Backends::Sequel::KeyValue::Translation.descendants].uniq + klass.define_method :after_destroy do + super() + + @mobility_after_destroy_translation_classes = [] unless defined?(@mobility_after_destroy_translation_classes) + (translation_classes - @mobility_after_destroy_translation_classes).each do |translation_class| + translation_class.where(:"#{b.belongs_to}_id" => id, :"#{b.belongs_to}_type" => self.class.name).destroy + end + @mobility_after_destroy_translation_classes += translation_classes + end + end + private def join_translations(dataset, attr, locale, join_type) @@ -74,7 +131,7 @@ def visit(predicate, locale) visit_sql_identifier(predicate, locale) when ::Sequel::SQL::BooleanExpression visit_boolean(predicate, locale) - when ::Sequel::SQL::Expression + when ::Sequel::SQL::ComplexExpression visit(predicate.args, locale) else {} @@ -123,61 +180,13 @@ def visit_sql_identifier(identifier, locale) end end - backend = self - - setup do |attributes, options| - association_name = options[:association_name] - translation_class = options[:class_name] - key_column = options[:key_column] - value_column = options[:value_column] - belongs_to = options[:belongs_to] - belongs_to_id = :"#{belongs_to}_id" - belongs_to_type = :"#{belongs_to}_type" - - # Track all attributes for this association, so that we can limit the scope - # of keys for the association to only these attributes. We need to track the - # attributes assigned to the association in case this setup code is called - # multiple times, so we don't "forget" earlier attributes. - # - attrs_method_name = :"#{association_name}_attributes" - association_attributes = (instance_variable_get(:"@#{attrs_method_name}") || []) + attributes - instance_variable_set(:"@#{attrs_method_name}", association_attributes) - - one_to_many association_name, - reciprocal: belongs_to, - key: belongs_to_id, - reciprocal_type: :one_to_many, - conditions: { belongs_to_type => self.to_s, key_column => association_attributes }, - adder: proc { |translation| translation.update(belongs_to_id => pk, belongs_to_type => self.class.to_s) }, - remover: proc { |translation| translation.update(belongs_to_id => nil, belongs_to_type => nil) }, - clearer: proc { send_(:"#{association_name}_dataset").update(belongs_to_id => nil, belongs_to_type => nil) }, - class: translation_class - - callback_methods = Module.new do - define_method :before_save do - super() - send(association_name).select { |t| attributes.include?(t.__send__(key_column)) && Util.blank?(t.__send__(value_column)) }.each(&:destroy) - end - define_method :after_save do - super() - attributes.each { |attribute| mobility_backends[attribute].save_translations } - end - end - include callback_methods - - # Clean up *all* leftover translations of this model, only once. - translation_classes = [translation_class, *Mobility::Backends::Sequel::KeyValue::Translation.descendants].uniq - define_method :after_destroy do - super() + setup do |attributes, _options, backend_class| + backend_class.define_one_to_many_association(self, attributes) + backend_class.define_save_callbacks(self, attributes) + backend_class.define_after_destroy_callback(self) - @mobility_after_destroy_translation_classes = [] unless defined?(@mobility_after_destroy_translation_classes) - (translation_classes - @mobility_after_destroy_translation_classes).each do |klass| - klass.where(belongs_to_id => id, belongs_to_type => self.class.name).destroy - end - @mobility_after_destroy_translation_classes += translation_classes - end include(mod = Module.new) - backend.define_column_changes(mod, attributes) + backend_class.define_column_changes(mod, attributes) end # Returns translation for a given locale, or initializes one if none is present. diff --git a/lib/mobility/backends/sequel/pg_hash.rb b/lib/mobility/backends/sequel/pg_hash.rb index 52f12fe2d..bacc5ddfc 100644 --- a/lib/mobility/backends/sequel/pg_hash.rb +++ b/lib/mobility/backends/sequel/pg_hash.rb @@ -33,9 +33,7 @@ def translations model[column_name.to_sym] end - backend = self - - setup do |attributes, options| + setup do |attributes, options, backend_class| columns = attributes.map { |attribute| (options[:column_affix] % attribute).to_sym } mod = Module.new do @@ -47,8 +45,8 @@ def translations end end include mod - backend.define_hash_initializer(mod, columns) - backend.define_column_changes(mod, attributes, column_affix: options[:column_affix]) + backend_class.define_hash_initializer(mod, columns) + backend_class.define_column_changes(mod, attributes, column_affix: options[:column_affix]) plugin :defaults_setter columns.each { |column| default_values[column] = {} } diff --git a/lib/mobility/backends/sequel/table.rb b/lib/mobility/backends/sequel/table.rb index 5f3494c34..bd1cb40c0 100644 --- a/lib/mobility/backends/sequel/table.rb +++ b/lib/mobility/backends/sequel/table.rb @@ -84,7 +84,7 @@ def visit(predicate, locale) visit_sql_identifier(predicate, locale) when ::Sequel::SQL::BooleanExpression visit_boolean(predicate, locale) - when ::Sequel::SQL::Expression + when ::Sequel::SQL::ComplexExpression visit(predicate.args, locale) else nil @@ -116,9 +116,7 @@ def visit_boolean(boolean, locale) end end - backend = self - - setup do |attributes, options| + setup do |attributes, options, backend_class| association_name = options[:association_name] subclass_name = options[:subclass_name] @@ -155,7 +153,7 @@ def visit_boolean(boolean, locale) include callback_methods include(mod = Module.new) - backend.define_column_changes(mod, attributes) + backend_class.define_column_changes(mod, attributes) end def translation_for(locale, **) diff --git a/lib/mobility/plugins/active_model/dirty.rb b/lib/mobility/plugins/active_model/dirty.rb index 30bd26fc7..3cd13a79b 100644 --- a/lib/mobility/plugins/active_model/dirty.rb +++ b/lib/mobility/plugins/active_model/dirty.rb @@ -153,7 +153,13 @@ def #{method_name}(attr_name, *rest#{kwargs}) # suffixes is simplest given they change from Rails version to version. def patterns @patterns ||= - (klass.attribute_method_matchers.map { |p| "#{p.prefix}%s#{p.suffix}" } - excluded_patterns) + begin + # Method name changes in Rails 7.1 + attribute_method_patterns = klass.respond_to?(:attribute_method_patterns) ? + klass.attribute_method_patterns : + klass.attribute_method_matchers + attribute_method_patterns.map { |p| "#{p.prefix}%s#{p.suffix}" } - excluded_patterns + end end private diff --git a/lib/mobility/plugins/active_record/column_fallback.rb b/lib/mobility/plugins/active_record/column_fallback.rb new file mode 100644 index 000000000..a07887824 --- /dev/null +++ b/lib/mobility/plugins/active_record/column_fallback.rb @@ -0,0 +1,66 @@ +# frozen-string-literal: true + +module Mobility + module Plugins +=begin + +Plugin to use an original column for a given locale, and otherwise use the backend. + +=end + module ActiveRecord + module ColumnFallback + extend Plugin + + requires :column_fallback, include: false + + included_hook do |_, backend_class| + backend_class.include BackendInstanceMethods + backend_class.extend BackendClassMethods + end + + def self.use_column_fallback?(options, locale) + case column_fallback = options[:column_fallback] + when TrueClass + locale == I18n.default_locale + when Array + column_fallback.include?(locale) + when Proc + column_fallback.call(locale) + else + false + end + end + + module BackendInstanceMethods + def read(locale, **) + if ColumnFallback.use_column_fallback?(options, locale) + model.read_attribute(attribute) + else + super + end + end + + def write(locale, value, **) + if ColumnFallback.use_column_fallback?(options, locale) + model.send(:write_attribute, attribute, value) + else + super + end + end + end + + module BackendClassMethods + def build_node(attr, locale) + if ColumnFallback.use_column_fallback?(options, locale) + model_class.arel_table[attr] + else + super + end + end + end + end + end + + register_plugin(:active_record_column_fallback, ActiveRecord::ColumnFallback) + end +end diff --git a/lib/mobility/plugins/active_record/query.rb b/lib/mobility/plugins/active_record/query.rb index 9803d4dd9..0811a36b3 100644 --- a/lib/mobility/plugins/active_record/query.rb +++ b/lib/mobility/plugins/active_record/query.rb @@ -163,9 +163,8 @@ def order(opts, *rest) define_method method_name do |*attrs, &block| return super(*attrs, &block) if (method_name == 'select' && block.present?) - if ::ActiveRecord::VERSION::STRING < '7.0' - return super(*attrs, &block) unless @klass.respond_to?(:mobility_attribute?) - end + return super(*attrs, &block) unless @klass.respond_to?(:mobility_attribute?) + return super(*attrs, &block) unless attrs.any?(&@klass.method(:mobility_attribute?)) keys = attrs.dup @@ -223,10 +222,13 @@ def _build(scope, opts, locale, invert) keys, predicates = opts.keys.map(&:to_s), [] + used_keys = [] + query_map = mods.inject(IDENTITY) do |qm, mod| - i18n_keys = mod.names & keys + i18n_keys = mod.names & keys - used_keys next qm if i18n_keys.empty? + used_keys += i18n_keys mod_predicates = i18n_keys.map do |key| build_predicate(scope.backend_node(key.to_sym, locale), opts.delete(key)) end diff --git a/lib/mobility/plugins/backend.rb b/lib/mobility/plugins/backend.rb index 16dd1dba8..3ceedbb0c 100644 --- a/lib/mobility/plugins/backend.rb +++ b/lib/mobility/plugins/backend.rb @@ -125,6 +125,14 @@ def [](name) return self[name.to_sym] if String === name self[name] = @model.class.mobility_backend_class(name).new(@model, name.to_s) end + + def marshal_dump + @model + end + + def marshal_load(model) + @model = model + end end module InstanceMethods @@ -151,16 +159,22 @@ def mobility_backend_class(name) raise KeyError, "No backend for: #{name}" end - def inherited(klass) - parent_classes = mobility_backend_classes.freeze # ensure backend classes are not modified after being inherited - klass.class_eval { @mobility_backend_classes = parent_classes.dup } - super - end - protected def mobility_backend_classes - @mobility_backend_classes ||= {} + if @mobility_backend_classes + @mobility_backend_classes + else + @mobility_backend_classes = {} + parent_class = self.superclass + while parent_class&.respond_to?(:mobility_backend_classes, true) + # ensure backend classes are not modified after being inherited + parent_class_classes = parent_class.mobility_backend_classes.freeze + @mobility_backend_classes.merge!(parent_class_classes) + parent_class = parent_class.superclass + end + @mobility_backend_classes + end end end diff --git a/lib/mobility/plugins/column_fallback.rb b/lib/mobility/plugins/column_fallback.rb new file mode 100644 index 000000000..64f1138e0 --- /dev/null +++ b/lib/mobility/plugins/column_fallback.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Mobility + module Plugins + module ColumnFallback + extend Plugin + + default false + + requires :backend, include: :before + end + + register_plugin(:column_fallback, ColumnFallback) + end +end diff --git a/lib/mobility/plugins/fallbacks.rb b/lib/mobility/plugins/fallbacks.rb index be318df0e..d7e27b187 100644 --- a/lib/mobility/plugins/fallbacks.rb +++ b/lib/mobility/plugins/fallbacks.rb @@ -117,8 +117,21 @@ module Fallbacks # Applies fallbacks plugin to attributes. Completely disables fallbacks # on model if option is +false+. included_hook do |_, backend_class| - fallbacks = options[:fallbacks] - backend_class.include(BackendReader.new(fallbacks, method(:generate_fallbacks))) unless fallbacks == false + unless options[:fallbacks] == false + backend_class.include(BackendInstanceMethods) + + fallbacks = + if options[:fallbacks].is_a?(Hash) + generate_fallbacks(options[:fallbacks]) + elsif options[:fallbacks] == true + generate_fallbacks({}) + else + ::Hash.new { [] } + end + + backend_class.singleton_class.attr_reader :fallbacks + backend_class.instance_variable_set(:@fallbacks, fallbacks) + end end private @@ -134,36 +147,17 @@ def [](locale) end end - class BackendReader < Module - def initialize(fallbacks_option, fallbacks_generator) - @fallbacks_generator = fallbacks_generator - define_read(convert_option_to_fallbacks(fallbacks_option)) - end - - private + module BackendInstanceMethods + def read(locale, fallback: true, **kwargs) + return super(locale, **kwargs) if !fallback || kwargs[:locale] - def define_read(fallbacks) - define_method :read do |locale, fallback: true, **options| - return super(locale, **options) if !fallback || options[:locale] - - locales = fallback == true ? fallbacks[locale] : [locale, *fallback] - locales.each do |fallback_locale| - value = super(fallback_locale, **options) - return value if Util.present?(value) - end - - super(locale, **options) + locales = fallback == true ? self.class.fallbacks[locale] : [locale, *fallback] + locales.each do |fallback_locale| + value = super(fallback_locale, **kwargs) + return value if Util.present?(value) end - end - def convert_option_to_fallbacks(option) - if option.is_a?(::Hash) - @fallbacks_generator[option] - elsif option == true - @fallbacks_generator[{}] - else - ::Hash.new { [] } - end + super(locale, **kwargs) end end end diff --git a/lib/mobility/plugins/fallthrough_accessors.rb b/lib/mobility/plugins/fallthrough_accessors.rb index 14fa6f88c..9e238f946 100644 --- a/lib/mobility/plugins/fallthrough_accessors.rb +++ b/lib/mobility/plugins/fallthrough_accessors.rb @@ -36,7 +36,7 @@ module FallthroughAccessors private def define_fallthrough_accessors(*names) - method_name_regex = /\A(#{names.join('|')})_([a-z]{2}(_[a-z]{2})?)(=?|\??)\z/.freeze + method_name_regex = /\A(#{names.join('|')})_([a-z]{2,3}(_[a-z]{2})?)(=?|\??)\z/.freeze define_method :method_missing do |method_name, *args, &block| if method_name =~ method_name_regex @@ -55,6 +55,13 @@ def define_fallthrough_accessors(*names) end end + # Following is needed in order to not swallow `kwargs` on ruby >= 3.0. + # Otherwise `kwargs` are not passed by `super` to a possible other + # `method_missing` defined like this: + # + # def method_missing(name, *args, **kwargs, &block); end + ruby2_keywords :method_missing + define_method :respond_to_missing? do |method_name, include_private = false| (method_name =~ method_name_regex) || super(method_name, include_private) end diff --git a/lib/mobility/plugins/sequel.rb b/lib/mobility/plugins/sequel.rb index 2fc7ac18b..7db6b2050 100644 --- a/lib/mobility/plugins/sequel.rb +++ b/lib/mobility/plugins/sequel.rb @@ -9,6 +9,7 @@ require_relative "./sequel/dirty" require_relative "./sequel/cache" require_relative "./sequel/query" +require_relative "./sequel/column_fallback" module Mobility module Plugins @@ -26,6 +27,7 @@ module Sequel requires :sequel_dirty requires :sequel_cache requires :sequel_query + requires :sequel_column_fallback included_hook do |klass| unless sequel_class?(klass) diff --git a/lib/mobility/plugins/sequel/column_fallback.rb b/lib/mobility/plugins/sequel/column_fallback.rb new file mode 100644 index 000000000..fdfd6aac6 --- /dev/null +++ b/lib/mobility/plugins/sequel/column_fallback.rb @@ -0,0 +1,66 @@ +# frozen-string-literal: true + +module Mobility +=begin + +Plugin to use an original column for a given locale, and otherwise use the backend. + +=end + module Plugins + module Sequel + module ColumnFallback + extend Plugin + + requires :column_fallback, include: false + + included_hook do |_, backend_class| + backend_class.include BackendInstanceMethods + backend_class.extend BackendClassMethods + end + + def self.use_column_fallback?(options, locale) + case column_fallback = options[:column_fallback] + when TrueClass + locale == I18n.default_locale + when Array + column_fallback.include?(locale) + when Proc + column_fallback.call(locale) + else + false + end + end + + module BackendInstanceMethods + def read(locale, **) + if ColumnFallback.use_column_fallback?(options, locale) + model[attribute.to_sym] + else + super + end + end + + def write(locale, value, **) + if ColumnFallback.use_column_fallback?(options, locale) + model[attribute.to_sym] = value + else + super + end + end + end + + module BackendClassMethods + def build_op(attr, locale) + if ColumnFallback.use_column_fallback?(options, locale) + ::Sequel::SQL::QualifiedIdentifier.new(model_class.table_name, attr.to_sym) + else + super + end + end + end + end + end + + register_plugin(:sequel_column_fallback, Sequel::ColumnFallback) + end +end diff --git a/lib/mobility/plugins/sequel/query.rb b/lib/mobility/plugins/sequel/query.rb index 041963d1e..434a41a6e 100644 --- a/lib/mobility/plugins/sequel/query.rb +++ b/lib/mobility/plugins/sequel/query.rb @@ -61,7 +61,7 @@ def method_missing(m, *args) locale = args[0] || @global_locale @locales |= [locale] @model_class.mobility_backend_class(m).build_op(m.to_s, locale) - elsif @model_class.columns.include?(m.to_s) + elsif @model_class.columns.include?(m) ::Sequel::SQL::QualifiedIdentifier.new(@model_class.table_name, m) else super @@ -135,10 +135,13 @@ def _build(dataset, cond, locale, query_method) keys, predicates = cond.keys, [] model = dataset.model + used_keys = [] + query_map = attribute_modules(model).inject(IDENTITY) do |qm, mod| - i18n_keys = mod.names.map(&:to_sym) & keys + i18n_keys = mod.names.map(&:to_sym) & keys - used_keys next qm if i18n_keys.empty? + used_keys += i18n_keys mod_predicates = i18n_keys.map do |key| build_predicate(dataset.backend_op(key, locale), cond.delete(key)) end diff --git a/lib/mobility/version.rb b/lib/mobility/version.rb index a895730ba..68a2830d7 100644 --- a/lib/mobility/version.rb +++ b/lib/mobility/version.rb @@ -7,9 +7,9 @@ def self.gem_version module VERSION MAJOR = 1 - MINOR = 1 - TINY = 2 - PRE = nil + MINOR = 3 + TINY = 0 + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/lib/rails/generators/mobility/backend_generators/base.rb b/lib/rails/generators/mobility/backend_generators/base.rb index c098346bd..8e2cfb90d 100644 --- a/lib/rails/generators/mobility/backend_generators/base.rb +++ b/lib/rails/generators/mobility/backend_generators/base.rb @@ -46,7 +46,9 @@ def data_source_exists? connection.data_source_exists?(table_name) end - delegate :connection, to: ::ActiveRecord::Base + def connection + ::ActiveRecord::Base.connection + end def truncate_index_name(index_name) if index_name.size < connection.index_name_length diff --git a/lib/rails/generators/mobility/templates/initializer.rb b/lib/rails/generators/mobility/templates/initializer.rb index a73cd686d..16a49247b 100644 --- a/lib/rails/generators/mobility/templates/initializer.rb +++ b/lib/rails/generators/mobility/templates/initializer.rb @@ -58,6 +58,19 @@ # Or uncomment this line to include but disable by default, and only enable # per model by passing +dirty: true+ to +translates+. # dirty false + + # Column Fallback + # + # Uncomment line below to fallback to original column. You can pass + # +column_fallback: true+ to +translates+ to return original column on + # default locale, or pass +column_fallback: [:en, :de]+ to +translates+ + # to return original column for those locales or pass + # +column_fallback: ->(locale) { ... }+ to +translates to evaluate which + # locales to return original column for. + # column_fallback + # + # Or uncomment this line to enable column fallback with a global default. + # column_fallback true # Fallbacks # diff --git a/mobility.gemspec b/mobility.gemspec index f6761eeaa..0624df8f6 100644 --- a/mobility.gemspec +++ b/mobility.gemspec @@ -19,7 +19,9 @@ Gem::Specification.new do |spec| spec.metadata['homepage_uri'] = spec.homepage spec.metadata['source_code_uri'] = 'https://github.com/shioyama/mobility' + spec.metadata['bug_tracker_uri'] = 'https://github.com/shioyama/mobility/issues' spec.metadata['changelog_uri'] = 'https://github.com/shioyama/mobility/blob/master/CHANGELOG.md' + spec.metadata['rubygems_mfa_required'] = 'true' spec.files = Dir['{lib/**/*,[A-Z]*}'] spec.bindir = "exe" diff --git a/spec/integration/sequel_compatibility_spec.rb b/spec/integration/sequel_compatibility_spec.rb index e363e0181..07a311b19 100644 --- a/spec/integration/sequel_compatibility_spec.rb +++ b/spec/integration/sequel_compatibility_spec.rb @@ -2,4 +2,44 @@ #TODO: Add general compatibility specs for Sequel describe "Sequel compatibility", orm: :sequel do + include Helpers::Plugins + include Helpers::Translates + # Enable all plugins that are enabled by default pre v1.0 + plugins :sequel, :reader, :writer, :cache, :dirty, :presence, :query, :fallbacks + + before do + stub_const 'Article', Class.new(Sequel::Model) + Article.dataset = DB[:articles] + Article + end + + describe "querying on translated and untranslated attributes" do + %i[key_value table].each do |backend| + #TODO: update querying examples to correctly test untranslated attributes + context "#{backend} backend" do + before do + options = { backend: backend, fallbacks: false } + options[:type] = :string if backend == :key_value + translates Article, :title, **options + end + let!(:article1) { Article.create(title: "foo", slug: "bar") } + let!(:article2) { Article.create( slug: "baz") } + let!(:article4) { Article.create(title: "foo" ) } + + it "works with hash arguments" do + expect(Article.i18n.where(title: "foo", slug: "bar").select_all(:articles).all).to eq([article1]) + expect(Article.i18n.where(title: "foo" ).select_all(:articles).all).to match_array([article1, article4]) + expect(Article.i18n.where(title: "foo", slug: "baz").select_all(:articles).all).to eq([]) + expect(Article.i18n.where( slug: "baz").select_all(:articles).all).to match_array([article2]) + end + + it "works with virtual rows" do + expect(Article.i18n { (title =~ "foo") & (slug =~ "bar") }.select_all(:articles).all).to eq([article1]) + expect(Article.i18n { (title =~ "foo") }.select_all(:articles).all).to match_array([article1, article4]) + expect(Article.i18n { (title =~ "foo") & (slug =~ "baz") }.select_all(:articles).all).to eq([]) + expect(Article.i18n { (slug =~ "baz") }.select_all(:articles).all).to eq([article2]) + end + end + end + end end diff --git a/spec/mobility/backend_spec.rb b/spec/mobility/backend_spec.rb index 95a001d0b..4aa612dc2 100644 --- a/spec/mobility/backend_spec.rb +++ b/spec/mobility/backend_spec.rb @@ -124,9 +124,9 @@ def each_locale end describe ".setup" do - before do - backend_class.class_eval do - setup do |attributes, options| + context "with two block arguments" do + before do + backend_class.setup do |attributes, options| def self.foo "foo" end @@ -141,44 +141,30 @@ def bar end end end - end - - it "stores setup as block which is called in model class" do - model_class = Class.new - backend_class.build_subclass(model_class, foo: "bar").setup_model(model_class, ["title"]) - expect(model_class.foo).to eq("foo") - expect(model_class.new.bar).to eq("bar") - end - - it "passes attributes and options to setup block when called on class" do - model_class = Class.new - backend_class.build_subclass(model_class, foo: "bar").setup_model(model_class, ["title"]) - expect(model_class.new.my_attributes).to eq(["title"]) - expect(model_class.new.my_options).to eq({ foo: "bar" }) - end - it "assigns setup block to descendants" do - model_class = Class.new - subclass = backend_class.build_subclass(model_class, foo: "bar") - Class.new(subclass).setup_model(model_class, ["title"]) - expect(model_class.foo).to eq("foo") - end + it "stores setup as block which is called in model class" do + model_class = Class.new + backend_class.build_subclass(model_class, foo: "bar").setup_model(model_class, ["title"]) + expect(model_class.foo).to eq("foo") + expect(model_class.new.bar).to eq("bar") + end - it "assigns options to descendants" do - model_class = Class.new - subclass = backend_class.build_subclass(model_class, foo: "bar") - expect(Class.new(subclass).options).to eq(foo: "bar") - end + it "passes attributes and options to setup block when called on class" do + model_class = Class.new + backend_class.build_subclass(model_class, foo: "bar").setup_model(model_class, ["title"]) + expect(model_class.new.my_attributes).to eq(["title"]) + expect(model_class.new.my_options).to eq({ foo: "bar" }) + end - it "assigns model_class to descendants" do - model_class = Class.new - subclass = backend_class.build_subclass(model_class, foo: "bar") - expect(Class.new(subclass).model_class).to eq(model_class) - end + it "assigns setup block to descendants" do + model_class = Class.new + subclass = backend_class.build_subclass(model_class, foo: "bar") + subclass.setup_model(model_class, ["title"]) + expect(model_class.foo).to eq("foo") + end - it "concatenates blocks when called multiple times" do - backend_class.class_eval do - setup do |attributes, options| + it "concatenates blocks when called multiple times" do + backend_class.setup do |attributes, options| def self.foo "foo2" end @@ -189,24 +175,98 @@ def self.foobar "foobar" end end + model_class = Class.new + backend_class.build_subclass(model_class, foo: "bar").setup_model(model_class, ["title", "content"]) + + aggregate_failures do + expect(model_class.foo).to eq("foo2") + expect(model_class.new.baz).to eq("titlecontent baz") + expect(model_class.foobar).to eq("foobar") + end end - model_class = Class.new - backend_class.build_subclass(model_class, foo: "bar").setup_model(model_class, ["title", "content"]) + end + + context "with 3 block arguments" do + before do + backend_class.setup do |attributes, options, klass| + define_method :my_attributes do + attributes + end + define_method :my_options do + options + end + define_method :my_backend_class do + klass + end + end + end + + it "passes configured backend class as third argument" do + model_class = Class.new + configured_backend_class = backend_class.build_subclass(model_class, foo: "bar") + configured_backend_class.setup_model(model_class, ["title"]) + + model = model_class.new + + expect(model.my_attributes).to eq(["title"]) + expect(model.my_options).to eq({ foo: "bar" }) + expect(model.my_backend_class).to eq(configured_backend_class) + end + + it "works with block concatenation" do + backend_class.setup do |attributes, options| + define_method :my_attributes_2 do + attributes + end + end + model_class = Class.new + configured_backend_class = backend_class.build_subclass(model_class, foo: "bar") + configured_backend_class.setup_model(model_class, ["title"]) + + model = model_class.new - aggregate_failures do - expect(model_class.foo).to eq("foo2") - expect(model_class.new.baz).to eq("titlecontent baz") - expect(model_class.foobar).to eq("foobar") + aggregate_failures do + expect(model.my_attributes).to eq(["title"]) + expect(model.my_options).to eq({ foo: "bar" }) + expect(model.my_backend_class).to eq(configured_backend_class) + end end end end + + describe ".build_subclass" do + it "raises when subclassed again" do + model_class = Class.new + subclass = backend_class.build_subclass(model_class, foo: "bar") + expect { Class.new(subclass) }.to raise_error(Mobility::Backend::ConfiguredError) + end + end + + describe "methods which raise unless called on configured subclass" do + it "raises when unconfigured" do + expect { backend_class.options }.to raise_error(Mobility::Backend::UnconfiguredError) + expect { backend_class.model_class }.to raise_error(Mobility::Backend::UnconfiguredError) + expect { backend_class.setup_model(Class.new, {}) }.to raise_error(Mobility::Backend::UnconfiguredError) + end + + it "returns value when configured" do + model_class = double("model class") + options = double("options") + subclass = backend_class.build_subclass(model_class, options) + expect(subclass.model_class).to eq(model_class) + expect(subclass.options).to eq(options) + end + end end - describe ".inspect" do + describe "#inspect on configured backend" do it "returns superclass name" do backend = stub_const 'MyBackend', Class.new + model_class = stub_const 'Model', Class.new backend.include(described_class) - expect(Class.new(backend).inspect).to match(/MyBackend/) + options = { foo: "bar" } + subclass = backend.build_subclass(model_class, options) + expect(subclass.inspect).to match(/MyBackend/) end end end diff --git a/spec/mobility/backends/active_record/container_spec.rb b/spec/mobility/backends/active_record/container_spec.rb index 19016ee57..0f2a16d5e 100644 --- a/spec/mobility/backends/active_record/container_spec.rb +++ b/spec/mobility/backends/active_record/container_spec.rb @@ -20,6 +20,36 @@ include_accessor_examples 'ContainerPost' include_dup_examples 'ContainerPost' include_cache_key_examples 'ContainerPost' + + it 'does not change translations and dirty tracking' do + post = ContainerPost.create! + + aggregate_failures "on access" do + expect { post.title } + .to not_change { post.translations }.from({}) + .and not_change { post.changes }.from({}) + .and not_change { post.changed? }.from(false) + end + + aggregate_failures "on reload" do + expect { post.reload } + .to not_change { post.translations }.from({}) + .and not_change { post.changes }.from({}) + .and not_change { post.changed? }.from(false) + end + end + + it 'deletes locale hash if last attribute is removed' do + post = ContainerPost.create! + + ::Mobility.with_locale(:en) { post.title = 'Title en' } + ::Mobility.with_locale(:de) { post.title = 'Title de' } + + expect { post.title = nil } + .to change { post.translations } + .from({ "en" => { "title" => "Title en" }, "de" => { "title" => "Title de" }}) + .to({ "de" => { "title" => "Title de" }}) + end end context "with query plugin" do diff --git a/spec/mobility/backends/active_record/hstore_spec.rb b/spec/mobility/backends/active_record/hstore_spec.rb index 065c3e612..a2814e01b 100644 --- a/spec/mobility/backends/active_record/hstore_spec.rb +++ b/spec/mobility/backends/active_record/hstore_spec.rb @@ -27,6 +27,14 @@ include_dup_examples 'HstorePost' include_cache_key_examples 'HstorePost' + it "does not impact dirty tracking on original column" do + post = HstorePost.create! + post.reload + + expect(post.my_title_i18n).to eq({}) + expect(post.changes).to eq({}) + end + describe "non-text values" do it "converts non-string types to strings when saving" do post = HstorePost.new diff --git a/spec/mobility/backends/active_record/json_spec.rb b/spec/mobility/backends/active_record/json_spec.rb index ddd84ca41..5d0796818 100644 --- a/spec/mobility/backends/active_record/json_spec.rb +++ b/spec/mobility/backends/active_record/json_spec.rb @@ -26,6 +26,14 @@ include_dup_examples 'JsonPost' include_cache_key_examples 'JsonPost' + it "does not impact dirty tracking on original column" do + post = JsonPost.create! + post.reload + + expect(post.my_title_i18n).to eq({}) + expect(post.changes).to eq({}) + end + describe "non-text values" do it "stores non-string types as-is when saving", active_record_geq: '5.0' do backend = post.mobility_backends[:title] diff --git a/spec/mobility/backends/active_record/jsonb_spec.rb b/spec/mobility/backends/active_record/jsonb_spec.rb index 99e9f62e5..06d3d4b73 100644 --- a/spec/mobility/backends/active_record/jsonb_spec.rb +++ b/spec/mobility/backends/active_record/jsonb_spec.rb @@ -25,6 +25,14 @@ include_serialization_examples 'JsonbPost', column_affix: column_affix include_dup_examples 'JsonbPost' include_cache_key_examples 'JsonbPost' + + it "does not impact dirty tracking on original column" do + post = JsonbPost.create! + post.reload + + expect(post.my_title_i18n).to eq({}) + expect(post.changes).to eq({}) + end end context "with query plugin" do diff --git a/spec/mobility/backends/active_record/table_spec.rb b/spec/mobility/backends/active_record/table_spec.rb index afe39bc3d..774768e63 100644 --- a/spec/mobility/backends/active_record/table_spec.rb +++ b/spec/mobility/backends/active_record/table_spec.rb @@ -243,7 +243,7 @@ let(:options) { {} } let(:backend_class) do - Class.new(described_class) { @model_class = Article } + described_class.build_subclass(Article, {}) end it "sets association_name" do diff --git a/spec/mobility/backends/sequel/table_spec.rb b/spec/mobility/backends/sequel/table_spec.rb index b6a09aa1b..291b7a470 100644 --- a/spec/mobility/backends/sequel/table_spec.rb +++ b/spec/mobility/backends/sequel/table_spec.rb @@ -148,7 +148,7 @@ let(:options) { {} } let(:backend_class) do - Class.new(described_class) { @model_class = Article } + described_class.build_subclass(Article, {}) end it "sets association_name" do diff --git a/spec/mobility/plugins/active_record/column_fallback_spec.rb b/spec/mobility/plugins/active_record/column_fallback_spec.rb new file mode 100644 index 000000000..0c182e32e --- /dev/null +++ b/spec/mobility/plugins/active_record/column_fallback_spec.rb @@ -0,0 +1,177 @@ +require "spec_helper" + +return unless defined?(ActiveRecord) + +require "mobility/plugins/active_record/column_fallback" + +describe Mobility::Plugins::ActiveRecord::ColumnFallback, orm: :active_record, type: :plugin do + plugins :active_record, :reader, :writer, :column_fallback + + let(:model_class) do + stub_const 'Article', Class.new(ActiveRecord::Base) + Article.include translations + Article + end + + context "column_fallback: true" do + plugin_setup :slug, column_fallback: true + + it "reads/writes from/to model column if locale is I18n.default_locale" do + instance.send(:write_attribute, :slug, "foo") + + Mobility.with_locale(:en) do + expect(instance.slug).to eq("foo") + instance.slug = "bar" + expect(instance.slug).to eq("bar") + expect(instance.read_attribute(:slug)).to eq("bar") + end + end + + it "reads/writes from/to backend if locale is not I18n.default_locale" do + instance.send(:write_attribute, :slug, "foo") + + Mobility.with_locale(:fr) do + expect(listener).to receive(:read).with(:fr, any_args).and_return("bar") + expect(instance.slug).to eq("bar") + + expect(listener).to receive(:write).with(:fr, "baz", any_args) + instance.slug = "baz" + end + end + end + + locales = [:de, :ja] + context "column_fallback: #{locales.inspect}" do + plugin_setup :slug, column_fallback: locales + + it "reads/writes from/to model column if locale is locales array" do + locales.each do |locale| + instance.send(:write_attribute, :slug, "foo") + + Mobility.with_locale(locale) do + expect(instance.slug).to eq("foo") + instance.slug = "bar" + expect(instance.slug).to eq("bar") + expect(instance.read_attribute(:slug)).to eq("bar") + end + end + end + + it "reads/writes from/to backend if locale is not in locales array" do + instance.send(:write_attribute, :slug, "foo") + + Mobility.with_locale(:fr) do + expect(listener).to receive(:read).with(:fr, any_args).and_return("bar") + expect(instance.slug).to eq("bar") + + expect(listener).to receive(:write).with(:fr, "baz", any_args) + instance.slug = "baz" + end + end + end + + context "column_fallback: ->(locale) { locale == :de }" do + plugin_setup :slug, column_fallback: ->(locale) { locale == :de } + + it "reads/writes from/to model column if proc returns false" do + instance.send(:write_attribute, :slug, "foo") + + Mobility.with_locale(:de) do + expect(instance.slug).to eq("foo") + instance.slug = "bar" + expect(instance.slug).to eq("bar") + expect(instance.read_attribute(:slug)).to eq("bar") + end + end + + it "reads/writes from/to backend if proc returns true" do + instance.send(:write_attribute, :slug, "foo") + + Mobility.with_locale(:fr) do + expect(listener).to receive(:read).with(:fr, any_args).and_return("bar") + expect(instance.slug).to eq("bar") + + expect(listener).to receive(:write).with(:fr, "baz", any_args) + instance.slug = "baz" + end + end + end + + describe "querying" do + plugins :active_record, :writer, :query, :column_fallback + + let(:model_class) do + stub_const 'Article', Class.new(ActiveRecord::Base) + end + + context "column_fallback: true" do + before do + translates model_class, :slug, backend: [:key_value, type: :string], column_fallback: true + end + + it "queries on model column if locale is I18n.default_locale" do + instance1 = model_class.new + instance1.send(:write_attribute, :slug, "foo") + instance1.save + + instance2 = model_class.new + Mobility.with_locale(:fr) { instance2.slug = "bar" } + instance2.save + + expect(model_class.i18n.find_by(slug: "foo", locale: :en)).to eq(instance1) + expect(model_class.i18n.find_by(slug: "foo", locale: :fr)).to eq(nil) + expect(model_class.i18n.find_by(slug: "bar", locale: :fr)).to eq(instance2) + expect(model_class.i18n.find_by(slug: "bar", locale: :en)).to eq(nil) + end + end + + locales = [:de, :ja] + context "column_fallback: #{locales.inspect}" do + before do + translates model_class, :slug, backend: [:key_value, type: :string], column_fallback: locales + end + + it "queries on model column if locale is locales array" do + instance1 = model_class.new + Mobility.with_locale(:de) { instance1.send(:write_attribute, :slug, "foo") } + instance1.save + + instance2 = model_class.new + Mobility.with_locale(:ja) { instance2.send(:write_attribute, :slug, "bar") } + instance2.save + + instance3 = model_class.new + Mobility.with_locale(:en) { instance3.slug = "baz" } + instance3.save + + expect(model_class.i18n.find_by(slug: "foo", locale: :de)).to eq(instance1) + expect(model_class.i18n.find_by(slug: "foo", locale: :en)).to eq(nil) + expect(model_class.i18n.find_by(slug: "bar", locale: :ja)).to eq(instance2) + expect(model_class.i18n.find_by(slug: "bar", locale: :en)).to eq(nil) + expect(model_class.i18n.find_by(slug: "baz", locale: :en)).to eq(instance3) + expect(model_class.i18n.find_by(slug: "baz", locale: :de)).to eq(nil) + end + end + + context "column_fallback: ->(locale) { locale == :de }" do + before do + translates model_class, :slug, backend: [:key_value, type: :string], column_fallback: ->(locale) { locale == :de } + end + + it "queries on model column if proc returns false" do + instance1 = model_class.new + Mobility.with_locale(:de) { instance1.send(:write_attribute, :slug, "foo") } + instance1.save + + instance2 = model_class.new + Mobility.with_locale(:ja) { instance2.slug = "bar" } + instance2.save + + expect(model_class.i18n.find_by(slug: "foo", locale: :de)).to eq(instance1) + expect(model_class.i18n.find_by(slug: "foo", locale: :ja)).to eq(nil) + expect(model_class.i18n.find_by(slug: "bar", locale: :ja)).to eq(instance2) + expect(model_class.i18n.find_by(slug: "bar", locale: :de)).to eq(nil) + end + end + end +end diff --git a/spec/mobility/plugins/active_record/query_spec.rb b/spec/mobility/plugins/active_record/query_spec.rb index 1368eb10f..5ae31dc73 100644 --- a/spec/mobility/plugins/active_record/query_spec.rb +++ b/spec/mobility/plugins/active_record/query_spec.rb @@ -160,7 +160,7 @@ m = ActiveRecord::Migration.new m.verbose = false - m.create_table :cars + m.create_table(:cars) { |t| t.string :name } stub_const('Car', Class.new(ActiveRecord::Base) do has_many :parking_lots has_many :car_parts @@ -190,6 +190,32 @@ query = ParkingLot.includes(car: :car_parts).references(:car).merge(Car.i18n) expect { query.first }.not_to raise_error expect { query.order(:car_id) }.not_to raise_error + expect { query.select(:name) }.not_to raise_error + end + end + + describe "regression for #564" do + it "works if translates is called multiple times" do + stub_const 'Article', Class.new(ActiveRecord::Base) + 2.times { translates Article, :title, backend: :table } + + article = Article.create(title: "Title") + + expect(Article.i18n.where(title: "Title")).to eq([article]) + end + + it "handles intersecting attribute declarations" do + stub_const 'Article', Class.new(ActiveRecord::Base) + translates Article, :title, :content, backend: :key_value, type: :string + + # title defined below clobbers title defined above + translates Article, :title, backend: :table + + article1 = Article.create(title: "Title") + article2 = Article.create(title: "Title", content: "Content") + + expect(Article.i18n.where(title: "Title")).to match_array([article1, article2]) + expect(Article.i18n.where(title: "Title", content: "Content")).to eq([article2]) end end end diff --git a/spec/mobility/plugins/backend_spec.rb b/spec/mobility/plugins/backend_spec.rb index de29408b8..c5a4fa154 100644 --- a/spec/mobility/plugins/backend_spec.rb +++ b/spec/mobility/plugins/backend_spec.rb @@ -19,7 +19,8 @@ describe "#included" do it "calls build_subclass on backend class with options merged with default options" do - expect(backend_class).to receive(:build_subclass).with(model_class, hash_including(foo: "bar")).and_return(Class.new(backend_class)) + configured_backend_class = double.as_null_object + expect(backend_class).to receive(:build_subclass).with(model_class, hash_including(foo: "bar")).and_return(configured_backend_class) translations = translations_class.new("title", backend: backend_class, foo: "bar") model_class.include translations end @@ -33,11 +34,13 @@ it "freezes backend options after inclusion into model class" do translations = translations_class.new("title", backend: backend_class) model_class.include translations - expect(backend_class.options).to be_frozen + expect(translations.backend_class.options).to be_frozen end it "calls setup_model on backend class with model_class and attributes" do - expect(backend_class).to receive(:setup_model).with(model_class, ["title"]) + configured_backend_class = double("configured backend class") + allow(backend_class).to receive(:build_subclass).and_return(configured_backend_class) + expect(configured_backend_class).to receive(:setup_model).with(model_class, ["title"]) model_class.include translations_class.new("title", backend: backend_class) end @@ -109,10 +112,31 @@ mod1 = translations_class.new("title", backend: backend_class_1) model_class.include mod1 - Class.new(model_class) + # accessing mobility_backend_classes necessary to trigger parent freeze + Class.new(model_class).send(:mobility_backend_classes) expect(model_class.send(:mobility_backend_classes)).to be_frozen end + + it "works with subclasses of subclasses" do + backend_class_1 = Class.new + backend_class_1.include Mobility::Backend + + model_subclass = Class.new(model_class) + model_subclass_2 = Class.new(model_subclass) + + # we specifically include translations after subclassing + mod1 = translations_class.new("title", backend: backend_class_1) + model_class.include mod1 + + title_backend_class_1 = model_class.mobility_backend_class("title") + title_backend_class_2 = model_subclass.mobility_backend_class("title") + title_backend_class_3 = model_subclass_2.mobility_backend_class("title") + + expect(title_backend_class_1).to be <(backend_class_1) + expect(title_backend_class_2).to be <(backend_class_1) + expect(title_backend_class_3).to be <(backend_class_1) + end end describe "#mobility_backends" do @@ -294,8 +318,10 @@ def OtherBackendClass.valid_keys expect(translations_class.new("title").inspect).to eq("#") end - it "calls setup_model on backend" do - expect(FooBackend).to receive(:setup_model).with(model_class, ["title"]) + it "calls setup_model on configured backend" do + configured_backend_class = double + expect(FooBackend).to receive(:build_subclass).with(model_class, backend: [:foo, {}]).and_return(configured_backend_class) + expect(configured_backend_class).to receive(:setup_model).with(model_class, ["title"]) model_class.include translations_class.new("title") end end diff --git a/spec/mobility/plugins/fallthrough_accessors_spec.rb b/spec/mobility/plugins/fallthrough_accessors_spec.rb index e01f72971..b46cbead0 100644 --- a/spec/mobility/plugins/fallthrough_accessors_spec.rb +++ b/spec/mobility/plugins/fallthrough_accessors_spec.rb @@ -22,7 +22,7 @@ def title=(_, **); end it_behaves_like "locale accessor", :title, 'en' it_behaves_like "locale accessor", :title, 'de' it_behaves_like "locale accessor", :title, 'pt-BR' - it_behaves_like "locale accessor", :title, 'ru' + it_behaves_like "locale accessor", :title, 'rus' it 'passes arguments and options to super when method does not match' do mod = Module.new do @@ -40,6 +40,22 @@ def method_missing(method_name, *args, &block) expect(instance.foo(**options)).to eq([options]) end + it 'passes kwargs to super when method does not match' do + mod = Module.new do + def method_missing(method_name, *args, **kwargs, &block) + (method_name == :foo) ? [args, kwargs] : super + end + end + + model_class = Class.new + model_class.include translations, mod + + instance = model_class.new + + kwargs = { some: 'params' } + expect(instance.foo(**kwargs)).to eq([[], kwargs]) + end + it 'does not pass on empty keyword options hash to super' do mod = Module.new do def method_missing(method_name, *args, &block) diff --git a/spec/mobility/plugins/sequel/column_fallback_spec.rb b/spec/mobility/plugins/sequel/column_fallback_spec.rb new file mode 100644 index 000000000..7a48897b7 --- /dev/null +++ b/spec/mobility/plugins/sequel/column_fallback_spec.rb @@ -0,0 +1,182 @@ +require "spec_helper" + +return unless defined?(Sequel) + +require "mobility/plugins/sequel/column_fallback" + +describe Mobility::Plugins::Sequel::ColumnFallback, orm: :sequel, type: :plugin do + plugins :sequel, :backend, :reader, :writer, :column_fallback + + let(:model_class) do + stub_const 'Article', Class.new(Sequel::Model) + Article.dataset = DB[:articles] + Article.include translations + Article + end + + context "column_fallback: true" do + plugin_setup :slug, column_fallback: true + + it "reads/writes from/to model column if locale is I18n.default_locale" do + instance[:slug] = "foo" + + Mobility.with_locale(:en) do + expect(instance.slug).to eq("foo") + instance.slug = "bar" + expect(instance.slug).to eq("bar") + expect(instance[:slug]).to eq("bar") + end + end + + it "reads/writes from/to backend if locale is not I18n.default_locale" do + instance[:slug] = "foo" + + Mobility.with_locale(:fr) do + expect(listener).to receive(:read).with(:fr, any_args).and_return("bar") + expect(instance.slug).to eq("bar") + + expect(listener).to receive(:write).with(:fr, "baz", any_args) + instance.slug = "baz" + end + end + end + + locales = [:de, :ja] + context "column_fallback: #{locales.inspect}" do + plugin_setup :slug, column_fallback: locales + + it "reads/writes from/to model column if locale is locales array" do + locales.each do |locale| + instance[:slug] = "foo" + + Mobility.with_locale(locale) do + expect(instance.slug).to eq("foo") + instance.slug = "bar" + expect(instance.slug).to eq("bar") + expect(instance[:slug]).to eq("bar") + end + end + end + + it "reads/writes from/to backend if locale is not in locales array" do + instance[:slug] = "foo" + + Mobility.with_locale(:fr) do + expect(listener).to receive(:read).with(:fr, any_args).and_return("bar") + expect(instance.slug).to eq("bar") + + expect(listener).to receive(:write).with(:fr, "baz", any_args) + instance.slug = "baz" + end + end + end + + context "column_fallback: ->(locale) { locale == :de }" do + plugin_setup :slug, column_fallback: ->(locale) { locale == :de } + + it "reads/writes from/to model column if proc returns false" do + instance[:slug] = "foo" + + Mobility.with_locale(:de) do + expect(instance.slug).to eq("foo") + instance.slug = "bar" + expect(instance.slug).to eq("bar") + expect(instance[:slug]).to eq("bar") + end + end + + it "reads/writes from/to backend if proc returns true" do + instance[:slug] = "foo" + + Mobility.with_locale(:fr) do + expect(listener).to receive(:read).with(:fr, any_args).and_return("bar") + expect(instance.slug).to eq("bar") + + expect(listener).to receive(:write).with(:fr, "baz", any_args) + instance.slug = "baz" + end + end + end + + describe "querying" do + # need to include because Sequel KeyValue backend depends on it, and we're + # using that backend in tests below + plugins :sequel, :writer, :query, :cache, :column_fallback + + let(:model_class) do + stub_const 'Article', Class.new(Sequel::Model) + Article.dataset = DB[:articles] + Article + end + + context "column_fallback: true" do + before do + translates model_class, :slug, backend: [:key_value, type: :string], column_fallback: true + end + + it "queries on model column if locale is I18n.default_locale" do + instance1 = model_class.new + instance1[:slug] = "foo" + instance1.save + + instance2 = model_class.new + Mobility.with_locale(:fr) { instance2.slug = "bar" } + instance2.save + + expect(model_class.i18n.where(slug: "foo", locale: :en).select_all(:articles).all).to match_array([instance1]) + expect(model_class.i18n.where(slug: "foo", locale: :fr).select_all(:articles).all).to eq([]) + expect(model_class.i18n.where(slug: "bar", locale: :fr).select_all(:articles).all).to eq([instance2]) + expect(model_class.i18n.where(slug: "bar", locale: :en).select_all(:articles).all).to eq([]) + end + end + + locales = [:de, :ja] + context "column_fallback: #{locales.inspect}" do + before do + translates model_class, :slug, backend: [:key_value, type: :string], column_fallback: locales + end + + it "queries on model column if locale is locales array" do + instance1 = model_class.new + Mobility.with_locale(:de) { instance1.slug = "foo" } + instance1.save + + instance2 = model_class.new + Mobility.with_locale(:ja) { instance2.slug = "bar" } + instance2.save + + instance3 = model_class.new + Mobility.with_locale(:en) { instance3.slug = "baz" } + instance3.save + + expect(model_class.i18n.where(slug: "foo", locale: :de).select_all(:articles).all).to eq([instance1]) + expect(model_class.i18n.where(slug: "foo", locale: :en).select_all(:articles).all).to eq([]) + expect(model_class.i18n.where(slug: "bar", locale: :ja).select_all(:articles).all).to eq([instance2]) + expect(model_class.i18n.where(slug: "bar", locale: :en).select_all(:articles).all).to eq([]) + expect(model_class.i18n.where(slug: "baz", locale: :en).select_all(:articles).all).to eq([instance3]) + expect(model_class.i18n.where(slug: "baz", locale: :de).select_all(:articles).all).to eq([]) + end + end + + context "column_fallback: ->(locale) { locale == :de }" do + before do + translates model_class, :slug, backend: [:key_value, type: :string], column_fallback: ->(locale) { locale == :de } + end + + it "queries on model column if proc returns false" do + instance1 = model_class.new + Mobility.with_locale(:de) { instance1.slug = "foo" } + instance1.save + + instance2 = model_class.new + Mobility.with_locale(:ja) { instance2.slug = "bar" } + instance2.save + + expect(model_class.i18n.where(slug: "foo", locale: :de).select_all(:articles).all).to eq([instance1]) + expect(model_class.i18n.where(slug: "foo", locale: :ja).select_all(:articles).all).to eq([]) + expect(model_class.i18n.where(slug: "bar", locale: :ja).select_all(:articles).all).to eq([instance2]) + expect(model_class.i18n.where(slug: "bar", locale: :de).select_all(:articles).all).to eq([]) + end + end + end +end diff --git a/spec/mobility/plugins/sequel/query_spec.rb b/spec/mobility/plugins/sequel/query_spec.rb index 3b8647930..943f0bab7 100644 --- a/spec/mobility/plugins/sequel/query_spec.rb +++ b/spec/mobility/plugins/sequel/query_spec.rb @@ -157,4 +157,17 @@ end.select_all(:articles).all).to eq([article_ja]) end end + + describe "regression for #564 (Sequel version)" do + it "works if translates is called multiple times" do + stub_const 'Article', Class.new(Sequel::Model) + Article.dataset = DB[:articles] + + 2.times { translates Article, :title, backend: :table } + + article = Article.create(title: "Title") + + expect(Article.i18n.where(title: "Title").select_all(:articles).all).to eq([article]) + end + end end diff --git a/spec/support/matchers/not_change.rb b/spec/support/matchers/not_change.rb new file mode 100644 index 000000000..93f5b66e5 --- /dev/null +++ b/spec/support/matchers/not_change.rb @@ -0,0 +1 @@ +RSpec::Matchers.define_negated_matcher :not_change, :change