From cc1e621bf695d32ffcbe0887e2561f33d3917bfe Mon Sep 17 00:00:00 2001 From: Chris Salzberg Date: Wed, 18 Apr 2018 11:47:33 +0900 Subject: [PATCH] Allow passing locale to queries --- .../active_record/column/query_methods.rb | 47 ++++++++++++++----- .../active_record/key_value/query_methods.rb | 17 ++++--- .../active_record/pg_query_methods.rb | 40 +++++++++------- .../active_record/table/query_methods.rb | 15 +++--- .../shared_examples/querying_examples.rb | 39 ++++++++++----- 5 files changed, 103 insertions(+), 55 deletions(-) diff --git a/lib/mobility/backends/active_record/column/query_methods.rb b/lib/mobility/backends/active_record/column/query_methods.rb index b2c7ad466..d77f58c73 100644 --- a/lib/mobility/backends/active_record/column/query_methods.rb +++ b/lib/mobility/backends/active_record/column/query_methods.rb @@ -4,12 +4,23 @@ module Mobility module Backends class ActiveRecord::Column::QueryMethods < ActiveRecord::QueryMethods - def initialize(attributes, _) + attr_reader :arel_table + + def initialize(attributes, options) super + @arel_table = options[:model_class].arel_table + q = self define_method :where! do |opts, *rest| - super(q.convert_opts(opts), *rest) + if i18n_keys = q.extract_attributes(opts) + opts = opts.with_indifferent_access + query = q.create_query!(opts, i18n_keys, locale: opts.delete(:locale)) + + opts.empty? ? super(query) : super(opts, *rest).where(query) + else + super(opts, *rest) + end end end @@ -19,20 +30,34 @@ def extended(relation) mod = Module.new do define_method :not do |opts, *rest| - super(q.convert_opts(opts), *rest) + if i18n_keys = q.extract_attributes(opts) + opts = opts.with_indifferent_access + query = q.create_query!(opts, i18n_keys, inverse: true, locale: opts.delete(:locale)) + + opts.empty? ? super(query) : super(opts, *rest).where.not(query) + else + super(opts, *rest) + end end end relation.mobility_where_chain.include(mod) end - def convert_opts(opts) - if i18n_keys = extract_attributes(opts) - opts = opts.with_indifferent_access - i18n_keys.each do |attr| - opts[Column.column_name_for(attr)] = collapse opts.delete(attr) - end - end - opts + def create_query!(opts, keys, inverse: false, **options) + keys.map { |key| + nils, vals = Array.wrap(opts.delete(key)).uniq.partition(&:nil?) + + Array.wrap(options[:locale] || Mobility.locale).map { |locale| + column_name = Column.column_name_for(key, locale) + node = arel_table[column_name] + + next node.eq(nil) if vals.empty? + + query = vals.size == 1 ? node.eq(vals.first) : node.in(vals) + query = query.or(node.eq(nil)) unless nils.empty? + query + }.inject(&(inverse ? :and : :or)) + }.inject(&(inverse ? :or : :and)) end end end diff --git a/lib/mobility/backends/active_record/key_value/query_methods.rb b/lib/mobility/backends/active_record/key_value/query_methods.rb index 70e1e6fd5..b233aa179 100644 --- a/lib/mobility/backends/active_record/key_value/query_methods.rb +++ b/lib/mobility/backends/active_record/key_value/query_methods.rb @@ -21,10 +21,11 @@ def extended(relation) define_method :not do |opts, *rest| if i18n_keys = q.extract_attributes(opts) opts = opts.with_indifferent_access + locale = opts.delete(:locale) || Mobility.locale i18n_keys.each do |attr| opts["#{attr}_#{association_name}"] = { value: q.collapse(opts.delete(attr)) } end - super(opts, *rest).send(:"join_#{association_name}", *i18n_keys) + super(opts, *rest).send(:"join_#{association_name}", *i18n_keys, locale: locale) else super(opts, *rest) end @@ -36,16 +37,17 @@ def extended(relation) private def define_join_method(association_name, translation_class) - define_method :"join_#{association_name}" do |*attributes, **options| + define_method :"join_#{association_name}" do |*attributes, locale:, **options| attributes.inject(self) do |relation, attribute| t = translation_class.arel_table.alias("#{attribute}_#{association_name}") m = arel_table join_type = options[:outer_join] ? Arel::Nodes::OuterJoin : Arel::Nodes::InnerJoin + matches_locale = locale.is_a?(Array) ? t[:locale].in(locale) : t[:locale].eq(locale) relation.joins(m.join(t, join_type). on(t[:key].eq(attribute). - and(t[:locale].eq(Mobility.locale). - and(t[:translatable_type].eq(base_class.name). - and(t[:translatable_id].eq(m[:id]))))).join_sources) + and(t[:translatable_type].eq(base_class.name). + and(t[:translatable_id].eq(m[:id]). + and(matches_locale)))).join_sources) end end end @@ -56,13 +58,14 @@ def define_query_methods(association_name) define_method :where! do |opts, *rest| if i18n_keys = q.extract_attributes(opts) opts = opts.with_indifferent_access + locale = opts.delete(:locale) || Mobility.locale i18n_nulls = i18n_keys.reject { |key| opts[key] && Array(opts[key]).all? } i18n_keys.each do |attr| opts["#{attr}_#{association_name}"] = { value: q.collapse(opts.delete(attr)) } end super(opts, *rest). - send("join_#{association_name}", *(i18n_keys - i18n_nulls)). - send("join_#{association_name}", *i18n_nulls, outer_join: true) + send("join_#{association_name}", *(i18n_keys - i18n_nulls), locale: locale). + send("join_#{association_name}", *i18n_nulls, outer_join: true, locale: locale) else super(opts, *rest) end diff --git a/lib/mobility/backends/active_record/pg_query_methods.rb b/lib/mobility/backends/active_record/pg_query_methods.rb index 235588408..a36141881 100644 --- a/lib/mobility/backends/active_record/pg_query_methods.rb +++ b/lib/mobility/backends/active_record/pg_query_methods.rb @@ -42,7 +42,7 @@ def initialize(attributes, options) define_method :where! do |opts, *rest| if i18n_keys = q.extract_attributes(opts) opts = opts.with_indifferent_access - query = q.create_query!(opts, i18n_keys) + query = q.create_query!(opts, i18n_keys, locale: opts.delete(:locale)) opts.empty? ? super(query) : super(opts, *rest).where(query) else @@ -59,7 +59,7 @@ def extended(relation) define_method :not do |opts, *rest| if i18n_keys = q.extract_attributes(opts) opts = opts.with_indifferent_access - query = q.create_query!(opts, i18n_keys, inverse: true) + query = q.create_query!(opts, i18n_keys, inverse: true, locale: opts.delete(:locale)) super(opts, *rest).where(query) else @@ -77,11 +77,12 @@ def extended(relation) # @param [Array] keys Translated attribute names # @option [Boolean] inverse (false) If true, create a +not+ query # instead of a +where+ query + # @option [Symbol, Array, NilClass] locale Locale or array of locales # @return [Arel::Node] Arel node to pass to +where+ - def create_query!(opts, keys, inverse: false) + def create_query!(opts, keys, inverse: false, locale:) keys.map { |key| values = Array.wrap(opts.delete(key)).uniq - send(inverse ? :not_query : :where_query, key, values, Mobility.locale) + send(inverse ? :not_query : :where_query, key, values, Array.wrap(locale || Mobility.locale)) }.inject(&:and) end @@ -119,33 +120,38 @@ def column_name(attribute) # # @param [String] key Translated attribute name # @param [Array] values Values to match - # @param [Symbol] locale Locale to query for + # @param [Symbol, Array] locales Locale or locales to query for # @return [Arel::Node] Arel node to pass to +where+ - def where_query(key, values, locale) + def where_query(key, values, locales) nils, vals = values.partition(&:nil?) + vals = vals.map(&method(:quote)) - return absent(key, locale) if vals.empty? + locales.map { |locale| + next absent(key, locale) if vals.empty? - node = matches(key, locale) - vals = vals.map(&method(:quote)) + node = matches(key, locale) - query = vals.size == 1 ? node.eq(vals.first) : node.in(vals) - query = query.or(absent(key, locale)) unless nils.empty? - query + query = vals.size == 1 ? node.eq(vals.first) : node.in(vals) + query = query.or(absent(key, locale)) unless nils.empty? + query + }.inject(&:or) end # Create +not+ query for specified key and values # # @param [String] key Translated attribute name # @param [Array] values Values to match - # @param [Symbol] locale Locale to query for + # @param [Symbol, Array] locales Locale or locales to query for # @return [Arel::Node] Arel node to pass to +where+ - def not_query(key, values, locale) + def not_query(key, values, locales) vals = values.map(&method(:quote)) - node = matches(key, locale) - query = vals.size == 1 ? node.eq(vals.first) : node.in(vals) - query.not.and(exists(key, locale)) + locales.map { |locale| + node = matches(key, locale) + + query = vals.size == 1 ? node.eq(vals.first) : node.in(vals) + query.not.and(exists(key, locale)) + }.inject(&:or) end end end diff --git a/lib/mobility/backends/active_record/table/query_methods.rb b/lib/mobility/backends/active_record/table/query_methods.rb index f795ea999..44f378220 100644 --- a/lib/mobility/backends/active_record/table/query_methods.rb +++ b/lib/mobility/backends/active_record/table/query_methods.rb @@ -24,10 +24,11 @@ def extended(relation) define_method :not do |opts, *rest| if i18n_keys = q.extract_attributes(opts) opts = opts.with_indifferent_access + locale = opts.delete(:locale) || Mobility.locale i18n_keys.each do |attr| opts["#{translation_class.table_name}.#{attr}"] = q.collapse opts.delete(attr) end - super(opts, *rest).send("join_#{association_name}") + super(opts, *rest).send("join_#{association_name}", locale: locale) else super(opts, *rest) end @@ -39,14 +40,13 @@ def extended(relation) private def define_join_method(association_name, translation_class, foreign_key: nil, table_name: nil, **) - define_method :"join_#{association_name}" do |**options| + define_method :"join_#{association_name}" do |locale:, outer_join: false, **options| return self if joins_values.any? { |v| v.is_a?(Arel::Nodes::Join) && (v.left.name == table_name.to_s) } t = translation_class.arel_table m = arel_table - join_type = options[:outer_join] ? Arel::Nodes::OuterJoin : Arel::Nodes::InnerJoin - joins(m.join(t, join_type). - on(t[foreign_key].eq(m[:id]). - and(t[:locale].eq(Mobility.locale))).join_sources) + join_type = outer_join ? Arel::Nodes::OuterJoin : Arel::Nodes::InnerJoin + matches_locale = locale.is_a?(Array) ? t[:locale].in(locale) : t[:locale].eq(locale) + joins(m.join(t, join_type).on(t[foreign_key].eq(m[:id]).and(matches_locale)).join_sources) end end @@ -94,7 +94,8 @@ def define_query_methods(association_name, translation_class, **) options = { # We only need an OUTER JOIN if every value is either nil, or an # array with at least one nil value. - outer_join: opts.values_at(*i18n_keys).compact.all? { |v| !Array(v).all? } + outer_join: opts.values_at(*i18n_keys).compact.all? { |v| !Array(v).all? }, + locale: opts.delete(:locale) || Mobility.locale } i18n_keys.each do |attr| opts["#{translation_class.table_name}.#{attr}"] = q.collapse opts.delete(attr) diff --git a/spec/support/shared_examples/querying_examples.rb b/spec/support/shared_examples/querying_examples.rb index da310bf3f..9625de3b4 100644 --- a/spec/support/shared_examples/querying_examples.rb +++ b/spec/support/shared_examples/querying_examples.rb @@ -45,6 +45,17 @@ expect(query_scope.where(attribute1 => "foo")).to eq([@ja_instance2]) end end + + it "returns result in locale specified by locale key" do + expect(query_scope.where(attribute1 => "foo", locale: :ja)).to match_array([@ja_instance2]) + expect(query_scope.where.not(attribute1 => "foo", locale: :ja)).to match_array([@ja_instance1]) + expect(query_scope.where.not(attribute1 => "foo", locale: :en)).to match_array([@instance2, @instance3, @instance4]) + end + + it "returns results in locales for array of locales" do + expect(query_scope.where(attribute1 => "foo", locale: [:en, :ja])).to match_array([@instance1, @instance5, @ja_instance2]) + expect(query_scope.where.not(attribute1 => "foo", locale: [:en, :ja])).to match_array([@instance2, @instance3, @instance4, @ja_instance1]) + end end context "with exists?" do @@ -194,21 +205,23 @@ expect(query_scope.where.not(attribute1 => "foo", published: true)).to eq([@instance6]) end - it "works with array of values" do - instance = model_class.create(attribute1 => "baz") - aggregate_failures do - expect(query_scope.where.not(attribute1 => ["foo", "bar"])).to match_array([instance]) - expect(query_scope.where.not(attribute1 => ["foo", nil])).to match_array([instance, @instance5, @instance6]) + context "with array of values" do + it "collapses clauses in array of values" do + instance = model_class.create(attribute1 => "baz") + expect(query_scope.where.not(attribute1 => ["foo", nil, nil])).to match_array([instance, @instance5, @instance6]) + expect(query_scope.where.not(attribute1 => ["foo", "foo", nil])).to match_array([instance, @instance5, @instance6]) + aggregate_failures do + expect(query_scope.where.not(attribute1 => ["foo", nil]).to_sql).to eq(query_scope.where.not(attribute1 => ["foo", nil, nil]).to_sql) + expect(query_scope.where.not(attribute1 => ["foo", nil]).to_sql).to eq(query_scope.where.not(attribute1 => ["foo", "foo", nil]).to_sql) + end end - end - it "collapses clauses in array of values" do - instance = model_class.create(attribute1 => "baz") - expect(query_scope.where.not(attribute1 => ["foo", nil, nil])).to match_array([instance, @instance5, @instance6]) - expect(query_scope.where.not(attribute1 => ["foo", "foo", nil])).to match_array([instance, @instance5, @instance6]) - aggregate_failures do - expect(query_scope.where.not(attribute1 => ["foo", nil]).to_sql).to eq(query_scope.where.not(attribute1 => ["foo", nil, nil]).to_sql) - expect(query_scope.where.not(attribute1 => ["foo", nil]).to_sql).to eq(query_scope.where.not(attribute1 => ["foo", "foo", nil]).to_sql) + it "returns records with matching translated attribute values" do + instance = model_class.create(attribute1 => "baz") + aggregate_failures do + expect(query_scope.where.not(attribute1 => ["foo", "bar"])).to match_array([instance]) + expect(query_scope.where.not(attribute1 => ["foo", nil])).to match_array([instance, @instance5, @instance6]) + end end end