diff --git a/lib/mobility/active_record.rb b/lib/mobility/active_record.rb index 8eb280211..377004168 100644 --- a/lib/mobility/active_record.rb +++ b/lib/mobility/active_record.rb @@ -12,7 +12,6 @@ module ActiveRecord def self.included(model_class) model_class.class_eval do - extend QueryMethod.new(Mobility.query_method) unless const_defined?(:UniquenessValidator) const_set(:UniquenessValidator, Class.new(::Mobility::ActiveRecord::UniquenessValidator)) @@ -20,16 +19,5 @@ def self.included(model_class) delegate :translated_attribute_names, to: :class end end - - class QueryMethod < Module - def initialize(query_method) - module_eval <<-EOM, __FILE__, __LINE__ + 1 - def #{query_method} - all - end - EOM - end - end - private_constant :QueryMethod end end diff --git a/lib/mobility/backends/active_record.rb b/lib/mobility/backends/active_record.rb index c4d53d190..2a554ad4a 100644 --- a/lib/mobility/backends/active_record.rb +++ b/lib/mobility/backends/active_record.rb @@ -1,19 +1,31 @@ module Mobility module Backends module ActiveRecord - def setup_query_methods(query_methods) - setup do |attributes, options| - extend(Module.new do - define_method ::Mobility.query_method do - super().extending(query_methods.new(attributes, options)) - end - end) - end - end - def self.included(backend_class) backend_class.include(Backend) - backend_class.extend(self) + backend_class.extend(ClassMethods) + end + + module ClassMethods + # @param [String] _attr Attribute name + # @param [Symbol] _locale Locale + def build_node(_attr, _locale) + raise NotImplementedError + end + + # @param [ActiveRecord::Relation] relation Relation to scope + # @param [Symbol] locale Locale + # @option [Boolean] invert + # @return [ActiveRecord::Relation] Relation with scope added + def add_translations(relation, _opts, _locale, invert: false) + relation + end + + private + + def build_quoted(value) + ::Arel::Nodes.build_quoted(value) + end end end end diff --git a/lib/mobility/backends/active_record/column.rb b/lib/mobility/backends/active_record/column.rb index debfc58ee..e5d1ff430 100644 --- a/lib/mobility/backends/active_record/column.rb +++ b/lib/mobility/backends/active_record/column.rb @@ -34,8 +34,6 @@ class ActiveRecord::Column include ActiveRecord include Column - require 'mobility/backends/active_record/column/query_methods' - # @!group Backend Accessors # @!macro backend_reader def read(locale, _ = {}) @@ -53,7 +51,9 @@ def each_locale available_locales.each { |l| yield(l) if present?(l) } end - setup_query_methods(QueryMethods) + def self.build_node(attr, locale) + model_class.arel_table[Column.column_name_for(attr, locale)] + end private diff --git a/lib/mobility/backends/active_record/column/query_methods.rb b/lib/mobility/backends/active_record/column/query_methods.rb deleted file mode 100644 index fe83235e9..000000000 --- a/lib/mobility/backends/active_record/column/query_methods.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true -require "mobility/backends/active_record/query_methods" - -module Mobility - module Backends - module ActiveRecord - class Column::QueryMethods < QueryMethods - def initialize(attributes, _) - super - q = self - - define_method :where! do |opts, *rest| - super(q.convert_opts(opts), *rest) - end - end - - def extended(relation) - super - q = self - - mod = Module.new do - define_method :not do |opts, *rest| - super(q.convert_opts(opts), *rest) - 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[Backends::Column.column_name_for(attr)] = collapse opts.delete(attr) - end - end - opts - end - end - Column.private_constant :QueryMethods - end - end -end diff --git a/lib/mobility/backends/active_record/container.rb b/lib/mobility/backends/active_record/container.rb index 9067d812f..d90774c64 100644 --- a/lib/mobility/backends/active_record/container.rb +++ b/lib/mobility/backends/active_record/container.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true require "mobility/backends/active_record" +require "mobility/arel/nodes/pg_ops" module Mobility module Backends @@ -11,9 +12,6 @@ module Backends class ActiveRecord::Container include ActiveRecord - require 'mobility/backends/active_record/container/json_query_methods' - require 'mobility/backends/active_record/container/jsonb_query_methods' - # @!method column_name # Returns name of json or jsonb column used to store translations # @return [Symbol] (:translations) Name of translations column @@ -59,6 +57,20 @@ def self.configure(options) end # @!endgroup + # @param [String] attr Attribute name + # @param [Symbol] locale Locale + def self.build_node(attr, locale) + column = model_class.arel_table[column_name] + quoted_locale = build_quoted(locale) + quoted_attr = build_quoted(attr) + case column_type + when :json + Arel::Nodes::Json.new(Arel::Nodes::JsonDashArrow.new(column, quoted_locale), quoted_attr) + when :jsonb + Arel::Nodes::Jsonb.new(Arel::Nodes::Jsonb.new(column, quoted_locale), quoted_attr) + end + end + # @!macro backend_iterator def each_locale model[column_name].each do |l, v| @@ -66,9 +78,7 @@ def each_locale end end - backend_class = self - - setup do |attributes, options| + setup do |_attributes, options| store options[:column_name], coder: Coder # Fix for duping depth-2 jsonb column in AR < 5.0 @@ -87,13 +97,6 @@ def initialize_dup(source) include const_set(module_name, dupable) end end - - query_methods = backend_class.const_get("#{options[:column_type].capitalize}QueryMethods") - extend(Module.new do - define_method ::Mobility.query_method do - super().extending(query_methods.new(attributes, options)) - end - end) end private diff --git a/lib/mobility/backends/active_record/container/json_query_methods.rb b/lib/mobility/backends/active_record/container/json_query_methods.rb deleted file mode 100644 index 963ad8205..000000000 --- a/lib/mobility/backends/active_record/container/json_query_methods.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true -require 'mobility/backends/active_record/pg_query_methods' -require "mobility/backends/active_record/query_methods" - -module Mobility - module Backends - module ActiveRecord - class Container::JsonQueryMethods < QueryMethods - include PgQueryMethods - attr_reader :column_name, :column - - def initialize(_attributes, options) - super - @column = arel_table[options[:column_name]] - end - - def matches(key, locale) - build_infix(:'->>', build_infix(:'->', column, build_quoted(locale)), build_quoted(key)) - end - - def exists(key, locale) - matches(key, locale).eq(nil).not - end - - def absent(key, locale) - matches(key, locale).eq(nil) - end - - def quote(value) - value.to_s - end - end - Container.private_constant :JsonQueryMethods - end - end -end diff --git a/lib/mobility/backends/active_record/container/jsonb_query_methods.rb b/lib/mobility/backends/active_record/container/jsonb_query_methods.rb deleted file mode 100644 index 9c2a43973..000000000 --- a/lib/mobility/backends/active_record/container/jsonb_query_methods.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true -require 'mobility/backends/active_record/pg_query_methods' -require "mobility/backends/active_record/query_methods" - -module Mobility - module Backends - module ActiveRecord - class Container::JsonbQueryMethods < QueryMethods - include PgQueryMethods - attr_reader :column - - def initialize(_attributes, options) - super - @column = arel_table[options[:column_name]] - end - - def matches(key, locale) - build_infix(:'->', build_infix(:'->', column, build_quoted(locale)), build_quoted(key)) - end - - def exists(key, locale) - build_infix(:'?', column, build_quoted(locale)).and( - build_infix(:'?', build_infix(:'->', column, build_quoted(locale)), build_quoted(key))) - end - - def quote(value) - build_quoted(value.to_json) - end - end - Container.private_constant :JsonbQueryMethods - end - end -end diff --git a/lib/mobility/backends/active_record/hstore.rb b/lib/mobility/backends/active_record/hstore.rb index 9b65f99c2..9fb258970 100644 --- a/lib/mobility/backends/active_record/hstore.rb +++ b/lib/mobility/backends/active_record/hstore.rb @@ -1,4 +1,5 @@ require 'mobility/backends/active_record/pg_hash' +require 'mobility/arel/nodes/pg_ops' module Mobility module Backends @@ -11,8 +12,6 @@ module Backends =end module ActiveRecord class Hstore < PgHash - require 'mobility/backends/active_record/hstore/query_methods' - # @!group Backend Accessors # @!macro backend_reader # @!method read(locale, options = {}) @@ -23,7 +22,12 @@ def write(locale, value, options = {}) end # @!endgroup - setup_query_methods(QueryMethods) + # @param [String] attr Attribute name + # @param [Symbol] locale Locale + def self.build_node(attr, locale) + column_name = column_affix % attr + Arel::Nodes::Hstore.new(model_class.arel_table[column_name], build_quoted(locale)) + end end end end diff --git a/lib/mobility/backends/active_record/hstore/query_methods.rb b/lib/mobility/backends/active_record/hstore/query_methods.rb deleted file mode 100644 index 26445a9c8..000000000 --- a/lib/mobility/backends/active_record/hstore/query_methods.rb +++ /dev/null @@ -1,25 +0,0 @@ -require 'mobility/backends/active_record/pg_query_methods' -require 'mobility/backends/active_record/query_methods' - -module Mobility - module Backends - module ActiveRecord - class Hstore::QueryMethods < QueryMethods - include PgQueryMethods - - def matches(key, locale) - build_infix(:'->', arel_table[column_name(key)], build_quoted(locale)) - end - - def exists(key, locale) - build_infix(:'?', arel_table[column_name(key)], build_quoted(locale)) - end - - def quote(value) - build_quoted(value) - end - end - Hstore.private_constant :QueryMethods - end - end -end diff --git a/lib/mobility/backends/active_record/json.rb b/lib/mobility/backends/active_record/json.rb index 7cd8d6514..2f76fa478 100644 --- a/lib/mobility/backends/active_record/json.rb +++ b/lib/mobility/backends/active_record/json.rb @@ -1,4 +1,5 @@ require 'mobility/backends/active_record/pg_hash' +require 'mobility/arel/nodes/pg_ops' module Mobility module Backends @@ -11,8 +12,6 @@ module Backends =end module ActiveRecord class Json < PgHash - require 'mobility/backends/active_record/json/query_methods' - # @!group Backend Accessors # # @!method read(locale, **options) @@ -31,7 +30,12 @@ class Json < PgHash # @return [String,Integer,Boolean] Updated value # @!endgroup - setup_query_methods(QueryMethods) + # @param [String] attr Attribute name + # @param [Symbol] locale Locale + def self.build_node(attr, locale) + column_name = column_affix % attr + Arel::Nodes::Json.new(model_class.arel_table[column_name], build_quoted(locale)) + end end end end diff --git a/lib/mobility/backends/active_record/json/query_methods.rb b/lib/mobility/backends/active_record/json/query_methods.rb deleted file mode 100644 index 2f40529e7..000000000 --- a/lib/mobility/backends/active_record/json/query_methods.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true -require 'mobility/backends/active_record/pg_query_methods' -require "mobility/backends/active_record/query_methods" - -module Mobility - module Backends - module ActiveRecord - class Json::QueryMethods < QueryMethods - include PgQueryMethods - - def matches(key, locale) - build_infix(:'->>', arel_table[column_name(key)], build_quoted(locale)) - end - - def exists(key, locale) - absent(key, locale).not - end - - def absent(key, locale) - matches(key, locale).eq(nil) - end - - def quote(value) - value.to_s - end - end - Json.private_constant :QueryMethods - end - end -end diff --git a/lib/mobility/backends/active_record/jsonb.rb b/lib/mobility/backends/active_record/jsonb.rb index 9260491fd..d26f96e33 100644 --- a/lib/mobility/backends/active_record/jsonb.rb +++ b/lib/mobility/backends/active_record/jsonb.rb @@ -1,4 +1,5 @@ require 'mobility/backends/active_record/pg_hash' +require 'mobility/arel/nodes/pg_ops' module Mobility module Backends @@ -11,8 +12,6 @@ module Backends =end module ActiveRecord class Jsonb < PgHash - require 'mobility/backends/active_record/jsonb/query_methods' - # @!group Backend Accessors # # @!method read(locale, **options) @@ -31,7 +30,12 @@ class Jsonb < PgHash # @return [String,Integer,Boolean] Updated value # @!endgroup - setup_query_methods(QueryMethods) + # @param [String] attr Attribute name + # @param [Symbol] locale Locale + def self.build_node(attr, locale) + column_name = column_affix % attr + Arel::Nodes::Jsonb.new(model_class.arel_table[column_name], build_quoted(locale)) + end end end end diff --git a/lib/mobility/backends/active_record/jsonb/query_methods.rb b/lib/mobility/backends/active_record/jsonb/query_methods.rb deleted file mode 100644 index 4ce2c7d0e..000000000 --- a/lib/mobility/backends/active_record/jsonb/query_methods.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true -require 'mobility/backends/active_record/pg_query_methods' -require "mobility/backends/active_record/query_methods" - -module Mobility - module Backends - module ActiveRecord - class Jsonb::QueryMethods < QueryMethods - include PgQueryMethods - - def matches(key, locale) - build_infix(:'->', arel_table[column_name(key)], build_quoted(locale)) - end - - def exists(key, locale) - build_infix(:'?', arel_table[column_name(key)], build_quoted(locale)) - end - - def quote(value) - build_quoted(value.to_json) - end - end - Jsonb.private_constant :QueryMethods - end - end -end diff --git a/lib/mobility/backends/active_record/key_value.rb b/lib/mobility/backends/active_record/key_value.rb index 270fe291b..992dffdac 100644 --- a/lib/mobility/backends/active_record/key_value.rb +++ b/lib/mobility/backends/active_record/key_value.rb @@ -29,21 +29,57 @@ class ActiveRecord::KeyValue include ActiveRecord include KeyValue - require 'mobility/backends/active_record/key_value/query_methods' - - # @!group Backend Configuration - # @option (see Mobility::Backends::KeyValue::ClassMethods#configure) - # @raise (see Mobility::Backends::KeyValue::ClassMethods#configure) - def self.configure(options) - super - if type = options[:type] - options[:association_name] ||= :"#{options[:type]}_translations" - options[:class_name] ||= Mobility::ActiveRecord.const_get("#{type.capitalize}Translation") + class << self + # @!group Backend Configuration + # @option (see Mobility::Backends::KeyValue::ClassMethods#configure) + # @raise (see Mobility::Backends::KeyValue::ClassMethods#configure) + def configure(options) + super + if type = options[:type] + options[:association_name] ||= :"#{options[:type]}_translations" + options[:class_name] ||= Mobility::ActiveRecord.const_get("#{type.capitalize}Translation") + end + rescue NameError + raise ArgumentError, "You must define a Mobility::ActiveRecord::#{type.capitalize}Translation class." + end + # @!endgroup + + # @param [String] attr Attribute name + # @param [Symbol] _locale Locale + def build_node(attr, _locale) + class_name.arel_table.alias("#{attr}_#{association_name}")[:value] + end + + # @param [ActiveRecord::Relation] relation Relation to scope + # @param [Hash] opts Hash of options for query + # @param [Symbol] locale Locale + # @option [Boolean] invert + def add_translations(relation, opts, locale, invert: false) + i18n_keys, i18n_nulls = partition_opts(opts) + + relation = join_translations(relation, i18n_keys, locale) + join_translations(relation, i18n_nulls, locale, outer_join: !invert) + end + + private + + def partition_opts(opts) + opts.keys.partition { |key| opts[key] && [*opts[key]].all? } + end + + def join_translations(relation, keys, locale, outer_join: false) + keys.inject(relation) do |r, key| + t = class_name.arel_table.alias("#{key}_#{association_name}") + m = model_class.arel_table + join_type = outer_join ? ::Arel::Nodes::OuterJoin : ::Arel::Nodes::InnerJoin + r.joins(m.join(t, join_type). + on(t[:key].eq(key). + and(t[:locale].eq(locale). + and(t[:translatable_type].eq(model_class.base_class.name). + and(t[:translatable_id].eq(m[:id]))))).join_sources) + end end - rescue NameError - raise ArgumentError, "You must define a Mobility::ActiveRecord::#{type.capitalize}Translation class." end - # @!endgroup setup do |attributes, options| association_name = options[:association_name] @@ -85,8 +121,6 @@ def self.configure(options) include DestroyKeyValueTranslations end - setup_query_methods(QueryMethods) - # Returns translation for a given locale, or builds one if none is present. # @param [Symbol] locale # @return [Mobility::ActiveRecord::TextTranslation,Mobility::ActiveRecord::StringTranslation] diff --git a/lib/mobility/backends/active_record/key_value/query_methods.rb b/lib/mobility/backends/active_record/key_value/query_methods.rb deleted file mode 100644 index 602854a51..000000000 --- a/lib/mobility/backends/active_record/key_value/query_methods.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true -require "mobility/backends/active_record/query_methods" - -module Mobility - module Backends - module ActiveRecord - class KeyValue::QueryMethods < QueryMethods - def initialize(attributes, association_name: nil, class_name: nil, **) - super - @association_name = association_name - - define_join_method(association_name, class_name) - define_query_methods(association_name) - end - - def extended(relation) - super - association_name = @association_name - q = self - - mod = Module.new do - define_method :not do |opts, *rest| - if i18n_keys = q.extract_attributes(opts) - opts = opts.with_indifferent_access - 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) - else - super(opts, *rest) - end - end - end - relation.mobility_where_chain.include(mod) - end - - private - - def define_join_method(association_name, translation_class) - define_method :"join_#{association_name}" do |*attributes, **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 - 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) - end - end - end - - def define_query_methods(association_name) - q = self - - define_method :where! do |opts, *rest| - if i18n_keys = q.extract_attributes(opts) - opts = opts.with_indifferent_access - i18n_nulls = i18n_keys.reject { |key| opts[key] && [*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) - else - super(opts, *rest) - end - end - end - end - KeyValue.private_constant :QueryMethods - end - end -end diff --git a/lib/mobility/backends/active_record/pg_query_methods.rb b/lib/mobility/backends/active_record/pg_query_methods.rb deleted file mode 100644 index 0a262cc9f..000000000 --- a/lib/mobility/backends/active_record/pg_query_methods.rb +++ /dev/null @@ -1,154 +0,0 @@ -# frozen_string_literal: true - -module Mobility - module Backends - module ActiveRecord -=begin - -Internal module builder defining query methods for Postgres backends. Including -class must define the following methods: - -- a method +matches+ which takes an attribute, and a locale to match, and - returns an Arel node which is used to check that the attribute has the - specified value in the specified locale -- a method +exists+ which takes an attribute and a locale and - returns an Arel node checking that a value exists for the attribute in the - specified locale -- a method +quote+ which quotes the value to be matched -- an optional method +absent+ which takes an attribute and a locale and returns - an Arel node checking that the value for the attribute does not exist in the - specified locale. Defaults to +exists(key, locale).not+. - -This module avoids a lot of duplication between hstore/json/jsonb/container -backend querying code. - -@see Mobility::Backends::ActiveRecord::Json::QueryMethods -@see Mobility::Backends::ActiveRecord::Jsonb::QueryMethods -@see Mobility::Backends::ActiveRecord::Hstore::QueryMethods -@see Mobility::Backends::ActiveRecord::Container::JsonQueryMethods -@see Mobility::Backends::ActiveRecord::Container::JsonbQueryMethods - -=end - module PgQueryMethods - attr_reader :arel_table, :column_affix - - def initialize(attributes, options) - super - @arel_table = options[:model_class].arel_table - @column_affix = options[:column_affix] - - q = self - - 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) - - opts.empty? ? super(query) : super(opts, *rest).where(query) - else - super(opts, *rest) - end - end - end - - def extended(relation) - super - q = self - - mod = Module.new do - 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) - - super(opts, *rest).where(query) - else - super(opts, *rest) - end - end - end - relation.mobility_where_chain.include(mod) - end - - # Create +where+ query for specified key and value - # - # @note This is a destructive operation, it will modify +opts+. - # @param [Hash] opts Hash of attribute/value pairs - # @param [Array] keys Translated attribute names - # @option [Boolean] inverse (false) If true, create a +not+ query - # instead of a +where+ query - # @return [Arel::Node] Arel node to pass to +where+ - def create_query!(opts, keys, inverse: false) - keys.map { |key| - values = Array.wrap(opts.delete(key)).uniq - send(inverse ? :not_query : :where_query, key, values, Mobility.locale) - }.inject(&:and) - end - - def matches(_key, _locale) - raise NotImplementedError - end - - def exists(_key, _locale) - raise NotImplementedError - end - - def quote(_value) - raise NotImplementedError - end - - def absent(key, locale) - exists(key, locale).not - end - - private - - def build_infix(*args) - arel_table.grouping(Arel::Nodes::InfixOperation.new(*args)) - end - - def build_quoted(value) - Arel::Nodes.build_quoted(value.to_s) - end - - def column_name(attribute) - column_affix % attribute - end - - # Create +where+ 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 - # @return [Arel::Node] Arel node to pass to +where+ - def where_query(key, values, locale) - nils, vals = values.partition(&:nil?) - - return absent(key, locale) if vals.empty? - - node = matches(key, locale) - vals = vals.map(&method(:quote)) - - query = vals.size == 1 ? node.eq(vals.first) : node.in(vals) - query = query.or(absent(key, locale)) unless nils.empty? - query - 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 - # @return [Arel::Node] Arel node to pass to +where+ - def not_query(key, values, locale) - 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)) - end - end - private_constant :PgQueryMethods - end - end -end diff --git a/lib/mobility/backends/active_record/serialized.rb b/lib/mobility/backends/active_record/serialized.rb index 8740bcf70..76b700c6c 100644 --- a/lib/mobility/backends/active_record/serialized.rb +++ b/lib/mobility/backends/active_record/serialized.rb @@ -30,8 +30,6 @@ class ActiveRecord::Serialized include ActiveRecord include HashValued - require 'mobility/backends/active_record/serialized/query_methods' - # @!group Backend Configuration # @param (see Backends::Serialized.configure) # @option (see Backends::Serialized.configure) @@ -42,13 +40,16 @@ def self.configure(options) end # @!endgroup + def self.build_node(attr, _locale) + raise ArgumentError, + "You cannot query on mobility attributes translated with the Serialized backend (#{attr})." + end + setup do |attributes, options| coder = { yaml: YAMLCoder, json: JSONCoder }[options[:format]] attributes.each { |attribute| serialize (options[:column_affix] % attribute), coder } end - setup_query_methods(QueryMethods) - # @!group Cache Methods # Returns column value as a hash # @return [Hash] diff --git a/lib/mobility/backends/active_record/serialized/query_methods.rb b/lib/mobility/backends/active_record/serialized/query_methods.rb deleted file mode 100644 index 1edf9afa2..000000000 --- a/lib/mobility/backends/active_record/serialized/query_methods.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true -require "mobility/backends/active_record/query_methods" - -module Mobility - module Backends - module ActiveRecord - class Serialized::QueryMethods < QueryMethods - include Backends::Serialized - - def initialize(attributes, _) - super - q = self - - define_method :where! do |opts, *rest| - q.check_opts(opts) || super(opts, *rest) - end - end - - def extended(relation) - super - q = self - - mod = Module.new do - define_method :not do |opts, *rest| - q.check_opts(opts) || super(opts, *rest) - end - end - relation.mobility_where_chain.include(mod) - end - end - Serialized.private_constant :QueryMethods - end - end -end diff --git a/lib/mobility/backends/active_record/table.rb b/lib/mobility/backends/active_record/table.rb index 1e6746f23..68cc3ce45 100644 --- a/lib/mobility/backends/active_record/table.rb +++ b/lib/mobility/backends/active_record/table.rb @@ -92,28 +92,89 @@ class ActiveRecord::Table include ActiveRecord include Table - require 'mobility/backends/active_record/table/query_methods' - - # @!group Backend Configuration - # @option options [Symbol] association_name (:translations) - # Name of association method - # @option options [Symbol] table_name Name of translation table - # @option options [Symbol] foreign_key Name of foreign key - # @option options [Symbol] subclass_name (:Translation) Name of subclass - # to append to model class to generate translation class - def self.configure(options) - table_name = options[:model_class].table_name - options[:table_name] ||= "#{table_name.singularize}_translations" - options[:foreign_key] ||= table_name.downcase.singularize.camelize.foreign_key - if (association_name = options[:association_name]).present? - options[:subclass_name] ||= association_name.to_s.singularize.camelize.freeze - else - options[:association_name] = :translations - options[:subclass_name] ||= :Translation + class << self + # @!group Backend Configuration + # @option options [Symbol] association_name (:translations) + # Name of association method + # @option options [Symbol] table_name Name of translation table + # @option options [Symbol] foreign_key Name of foreign key + # @option options [Symbol] subclass_name (:Translation) Name of subclass + # to append to model class to generate translation class + def configure(options) + table_name = options[:model_class].table_name + options[:table_name] ||= "#{table_name.singularize}_translations" + options[:foreign_key] ||= table_name.downcase.singularize.camelize.foreign_key + if (association_name = options[:association_name]).present? + options[:subclass_name] ||= association_name.to_s.singularize.camelize.freeze + else + options[:association_name] = :translations + options[:subclass_name] ||= :Translation + end + %i[foreign_key association_name subclass_name table_name].each { |key| options[key] = options[key].to_sym } + end + # @!endgroup + + # @param [String] attr Attribute name + # @param [Symbol] _locale Locale + def build_node(attr, _locale) + model_class.const_get(subclass_name).arel_table[attr] + end + + # Joins translations using either INNER/OUTER join appropriate to the + # query. So for example, using the Query plugin: + # + # Article.i18n.where(title: nil, content: nil) #=> OUTER JOIN (all nils) + # Article.i18n.where(title: "foo", content: nil) #=> INNER JOIN (one non-nil) + # + # In the first case, if we are in (say) the "en" locale, then we should + # match articles that have *no* article_translations with English + # locales (since no translation is equivalent to a nil value). If we + # used an INNER JOIN in the first case, an article with no English + # translations would be filtered out, so we use an OUTER JOIN. + # + # When deciding whether to use an outer or inner join, array-valued + # conditions are treated as nil if they have any values. + # + # Article.i18n.where(title: nil, content: ["foo", nil]) #=> OUTER JOIN (all nil or array with nil) + # Article.i18n.where(title: "foo", content: ["foo", nil]) #=> INNER JOIN (one non-nil) + # Article.i18n.where(title: ["foo", "bar"], content: ["foo", nil]) #=> INNER JOIN (one non-nil array) + # + # The logic also applies when a query has more than one where clause. + # + # Article.where(title: nil).where(content: nil) #=> OUTER JOIN (all nils) + # Article.where(title: nil).where(content: "foo") #=> INNER JOIN (one non-nil) + # Article.where(title: "foo").where(content: nil) #=> INNER JOIN (one non-nil) + # + # @param [ActiveRecord::Relation] relation Relation to scope + # @param [Hash] opts Hash of options for query + # @param [Symbol] locale Locale + # @option [Boolean] invert + def add_translations(relation, opts, locale, invert:) + outer_join = require_outer_join?(opts, invert) + return relation if already_joined?(relation, table_name, outer_join) + + t = model_class.const_get(subclass_name).arel_table + m = model_class.arel_table + join_type = outer_join ? ::Arel::Nodes::OuterJoin : ::Arel::Nodes::InnerJoin + relation.joins(m.join(t, join_type). + on(t[foreign_key].eq(m[:id]). + and(t[:locale].eq(locale))).join_sources) + end + + private + + def already_joined?(relation, table_name, outer_join) + if join = relation.joins_values.find { |v| (::Arel::Nodes::Join === v) && (v.left.name == table_name.to_s) } + return true if outer_join || ::Arel::Nodes::InnerJoin === join + relation.joins_values = relation.joins_values - [join] + end + false + end + + def require_outer_join?(opts, invert) + !invert && opts.values.compact.all? { |v| ![*v].all? } end - %i[foreign_key association_name subclass_name table_name].each { |key| options[key] = options[key].to_sym } end - # @!endgroup setup do |_attributes, options| association_name = options[:association_name] @@ -159,8 +220,6 @@ def self.configure(options) end end - setup_query_methods(QueryMethods) - # Returns translation for a given locale, or builds one if none is present. # @param [Symbol] locale def translation_for(locale, _) diff --git a/lib/mobility/backends/active_record/table/query_methods.rb b/lib/mobility/backends/active_record/table/query_methods.rb deleted file mode 100644 index e39a8d668..000000000 --- a/lib/mobility/backends/active_record/table/query_methods.rb +++ /dev/null @@ -1,105 +0,0 @@ -# frozen_string_literal: true -require "mobility/backends/active_record/query_methods" - -module Mobility - module Backends - module ActiveRecord - class Table::QueryMethods < QueryMethods - def initialize(attributes, association_name: nil, model_class: nil, subclass_name: nil, **options) - super - - @association_name = association_name - @translation_class = translation_class = model_class.const_get(subclass_name) - - define_join_method(association_name, translation_class, **options) - define_query_methods(association_name, translation_class, **options) - end - - def extended(relation) - super - association_name = @association_name - translation_class = @translation_class - q = self - - mod = Module.new do - define_method :not do |opts, *rest| - if i18n_keys = q.extract_attributes(opts) - opts = opts.with_indifferent_access - i18n_keys.each do |attr| - opts["#{translation_class.table_name}.#{attr}"] = q.collapse opts.delete(attr) - end - super(opts, *rest).send("join_#{association_name}") - else - super(opts, *rest) - end - end - end - relation.mobility_where_chain.include(mod) - end - - private - - def define_join_method(association_name, translation_class, foreign_key: nil, table_name: nil, **) - define_method :"join_#{association_name}" do |**options| - if join = joins_values.find { |v| (Arel::Nodes::Join === v) && (v.left.name == table_name.to_s) } - return self if (options[:outer_join] || Arel::Nodes::InnerJoin === join) - self.joins_values = joins_values - [join] - end - 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) - end - end - - def define_query_methods(association_name, translation_class, **) - q = self - - # Note that Mobility will try to use inner/outer joins appropriate to the query, - # so for example: - # - # Article.where(title: nil, content: nil) #=> OUTER JOIN (all nils) - # Article.where(title: "foo", content: nil) #=> INNER JOIN (one non-nil) - # - # In the first case, if we are in (say) the "en" locale, then we should match articles - # that have *no* article_translations with English locales (since no translation is - # equivalent to a nil value). If we used an inner join in the first case, an article - # with no English translations would be filtered out, so we use an outer join. - # - # When deciding whether to use an outer or inner join, array-valued - # conditions are treated as nil if they have any values. - # - # Article.where(title: nil, content: ["foo", nil]) #=> OUTER JOIN (all nil or array with nil) - # Article.where(title: "foo", content: ["foo", nil]) #=> INNER JOIN (one non-nil) - # Article.where(title: ["foo", "bar"], content: ["foo", nil]) #=> INNER JOIN (one non-nil array) - # - # The logic also applies when a query has more than one where clause. - # - # Article.where(title: nil).where(content: nil) #=> OUTER JOIN (all nils) - # Article.where(title: nil).where(content: "foo") #=> INNER JOIN (one non-nil) - # Article.where(title: "foo").where(content: nil) #=> INNER JOIN (one non-nil) - # - define_method :where! do |opts, *rest| - if i18n_keys = q.extract_attributes(opts) - opts = opts.with_indifferent_access - 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| ![*v].all? } - } - i18n_keys.each do |attr| - opts["#{translation_class.table_name}.#{attr}"] = q.collapse opts.delete(attr) - end - super(opts, *rest).send("join_#{association_name}", options) - else - super(opts, *rest) - end - end - end - end - Table.private_constant :QueryMethods - end - end -end diff --git a/lib/mobility/configuration.rb b/lib/mobility/configuration.rb index da1c2fe81..b619e6e43 100644 --- a/lib/mobility/configuration.rb +++ b/lib/mobility/configuration.rb @@ -108,8 +108,10 @@ def initialize @default_options = Options[{ cache: true, presence: true, + query: true }] @plugins = %i[ + query cache dirty fallbacks diff --git a/lib/mobility/plugins/active_record/query.rb b/lib/mobility/plugins/active_record/query.rb new file mode 100644 index 000000000..6f348c337 --- /dev/null +++ b/lib/mobility/plugins/active_record/query.rb @@ -0,0 +1,142 @@ +# frozen-string-literal: true +module Mobility + module Plugins +=begin + +Adds a scope which enables querying on translated attributes using +where+ and ++not+ as if they were normal attributes. Under the hood, this plugin uses the +generic +build_node+ and +add_translations+ methods implemented in each backend +class to build ActiveRecord queries from Arel nodes. + +=end + module ActiveRecord + module Query + class << self + def apply(attributes) + model_class = attributes.model_class + + unless const_defined?(:QueryMethod) + const_set :QueryMethod, Module.new + QueryMethod.module_eval <<-EOM, __FILE__, __LINE__ + 1 + def #{Mobility.query_method} + all.extending(QueryExtension) + end + EOM + private_constant :QueryMethod + end + + model_class.extend QueryMethod + model_class.extend FindByMethods.new(*attributes.names) + end + end + + module QueryExtension + def where!(opts, *rest) + QueryBuilder.build(self, opts) do |untranslated_opts| + untranslated_opts ? super(untranslated_opts, *rest) : super + end + end + + def where(opts = :chain, *rest) + opts == :chain ? WhereChain.new(spawn) : super + end + + class WhereChain < ::ActiveRecord::QueryMethods::WhereChain + def not(opts, *rest) + QueryBuilder.build(@scope, opts, invert: true) do |untranslated_opts| + untranslated_opts ? super(untranslated_opts, *rest) : super + end + end + end + + module QueryBuilder + class << self + def build(scope, where_opts, invert: false) + return yield unless Hash === where_opts + + locale = Mobility.locale + opts = where_opts.with_indifferent_access + + maps = build_maps!(scope.mobility.modules, opts, locale, invert: invert) + return yield if maps.empty? + + base = opts.empty? ? scope : yield(opts) + maps.inject(base) { |rel, map| map[rel] } + end + + private + + def build_maps!(mods, opts, locale, invert:) + keys = opts.keys.map(&:to_s) + mods.select { |mod| mod.options[:query] }.map { |mod| + next if (mod_keys = mod.names & keys).empty? + + mod_opts = opts.slice(*mod_keys) + predicates = mod_keys.map do |key| + build_predicate(mod, key, locale, opts.delete(key), invert: invert) + end + + ->(rel) do + mod.backend_class. + add_translations(rel, mod_opts, locale, invert: invert). + where(predicates.inject(&:and)) + end + }.compact + end + + def build_predicate(mod, key, locale, values, invert:) + node = mod.backend_class.build_node(key, locale) + + predicate = convert_to_predicate(node, values) + predicate = invert(predicate) if invert + predicate + end + + def convert_to_predicate(node, values) + nils, vals = partition_values(values) + + return node.eq(nil) if vals.empty? + + predicate = vals.length == 1 ? node.eq(vals.first) : node.in(vals) + predicate = predicate.or(node.eq(nil)) unless nils.empty? + predicate + end + + def partition_values(values) + Array.wrap(values).uniq.partition(&:nil?) + end + + # Adapted from AR::Relation::WhereClause#invert_predicate + def invert(node) + case node + when ::Arel::Nodes::In + ::Arel::Nodes::NotIn.new(node.left, node.right) + when ::Arel::Nodes::Equality + ::Arel::Nodes::NotEqual.new(node.left, node.right) + else + ::Arel::Nodes::Not.new(node) + end + end + end + end + + private_constant :WhereChain, :QueryBuilder + end + + class FindByMethods < Module + def initialize(*attributes) + attributes.each do |attribute| + module_eval <<-EOM, __FILE__, __LINE__ + 1 + def find_by_#{attribute}(value) + find_by(#{attribute}: value) + end + EOM + end + end + end + + private_constant :QueryExtension, :FindByMethods + end + end + end +end diff --git a/lib/mobility/plugins/query.rb b/lib/mobility/plugins/query.rb new file mode 100644 index 000000000..eb1e14907 --- /dev/null +++ b/lib/mobility/plugins/query.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +module Mobility + module Plugins +=begin + +@see {Mobility::Plugins::ActiveRecord::Query} + +=end + module Query + class << self + def apply(attributes, option) + if option + include_query_module(attributes) + end + end + + private + + def include_query_module(attributes) + if Loaded::ActiveRecord && attributes.model_class < ::ActiveRecord::Base + require "mobility/plugins/active_record/query" + ActiveRecord::Query.apply(attributes) + end + end + end + end + end +end diff --git a/spec/mobility/active_record_spec.rb b/spec/mobility/active_record_spec.rb index 9b0749cae..dc3a1a5dd 100644 --- a/spec/mobility/active_record_spec.rb +++ b/spec/mobility/active_record_spec.rb @@ -6,15 +6,6 @@ Article.include Mobility::ActiveRecord end - describe ".i18n" do - it "extends class with .i18n scope method" do - scope = double('scope') - expect(Article).to receive(:all).and_return(scope) - - expect(Article.i18n).to eq(scope) - end - end - describe "#translated_attribute_names" do it "delegates to class" do Article.instance_eval do diff --git a/spec/mobility/backends/active_record/container_spec.rb b/spec/mobility/backends/active_record/container_spec.rb index 28fc7061f..c4fcc77a3 100644 --- a/spec/mobility/backends/active_record/container_spec.rb +++ b/spec/mobility/backends/active_record/container_spec.rb @@ -31,8 +31,6 @@ aggregate_failures do expect(ContainerPost.i18n.where(title: nil).to_sql).to match /\?/ expect(ContainerPost.i18n.where(title: nil).to_sql).not_to match /NULL/ - expect(ContainerPost.i18n.where.not(title: "foo").to_sql).to match /\?/ - expect(ContainerPost.i18n.where.not(title: "foo").to_sql).not_to match /NULL/ end end diff --git a/spec/mobility/backends/active_record/jsonb_spec.rb b/spec/mobility/backends/active_record/jsonb_spec.rb index 2ebbd3640..086054d00 100644 --- a/spec/mobility/backends/active_record/jsonb_spec.rb +++ b/spec/mobility/backends/active_record/jsonb_spec.rb @@ -36,8 +36,6 @@ aggregate_failures do expect(JsonbPost.i18n.where(title: nil).to_sql).to match /\?/ expect(JsonbPost.i18n.where(title: nil).to_sql).not_to match /NULL/ - expect(JsonbPost.i18n.where.not(title: "foo").to_sql).to match /\?/ - expect(JsonbPost.i18n.where.not(title: "foo").to_sql).not_to match /NULL/ end end diff --git a/spec/mobility/backends/active_record/key_value_spec.rb b/spec/mobility/backends/active_record/key_value_spec.rb index 494b5e54c..8b513003d 100644 --- a/spec/mobility/backends/active_record/key_value_spec.rb +++ b/spec/mobility/backends/active_record/key_value_spec.rb @@ -402,17 +402,6 @@ end end end - - describe "Subclassing ActiveRecord::QueryMethods::WhereChain" do - it "extends relation.mobility_where_chain to handle translated attributes without creating memory leak" do - Post.i18n # call once to ensure class is defined - expect(Post.i18n.mobility_where_chain.ancestors).to include(::ActiveRecord::QueryMethods::WhereChain) - expect { 2.times { Post.i18n.where.not(title: "foo") } }.not_to change(Post.i18n.mobility_where_chain, :ancestors) - - scope = Post.i18n.where.not(title: "foo") - expect { scope.where.not(title: "foo") }.not_to change(scope, :ancestors) - end - end end describe "Model.i18n.find_by_" do diff --git a/spec/performance/attributes_spec.rb b/spec/performance/attributes_spec.rb index 214f2e11e..b18e2cb6b 100644 --- a/spec/performance/attributes_spec.rb +++ b/spec/performance/attributes_spec.rb @@ -13,7 +13,7 @@ extend Mobility end attributes = described_class.new(backend: :null) - expect { klass.include attributes }.to allocate_under(150).objects + expect { klass.include attributes }.to allocate_under(155).objects } end