# 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 +apply_scope+ methods implemented in each backend class to build ActiveRecord queries from Arel nodes. The plugin also adds +find_by_+ shortcuts for translated attributes. The query scope applies to all translated attributes once the plugin has been enabled for any one attribute on the model. =end module ActiveRecord module Query class << self def apply(attributes) attributes.model_class.class_eval do extend QueryMethod extend FindByMethods.new(*attributes.names) singleton_class.send :alias_method, Mobility.query_method, :__mobility_query_scope__ end end end module QueryMethod def __mobility_query_scope__(locale: Mobility.locale, &block) if block_given? VirtualRow.build_query(self, locale, &block) else all.extending(QueryExtension) end end end # Internal class to create a "clean room" for manipulating translated # attribute nodes in an instance-eval'ed block. Inspired by Sequel's # (much more sophisticated) virtual rows. class VirtualRow < BasicObject attr_reader :__backends def initialize(model_class, locale) @model_class, @locale, @__backends = model_class, locale, [] end def method_missing(m, *) if @model_class.mobility_attribute?(m) @__backends |= [@model_class.mobility_backend_class(m)] @model_class.mobility_backend_class(m).build_node(m, @locale) elsif @model_class.column_names.include?(m.to_s) @model_class.arel_table[m] else super end end class << self def build_query(klass, locale, &block) row = new(klass, locale) query = block.arity.zero? ? row.instance_eval(&block) : block.call(row) if ::ActiveRecord::Relation === query predicates = query.arel.constraints apply_scopes(klass.all, row.__backends, locale, predicates).merge(query) else apply_scopes(klass.all, row.__backends, locale, query).where(query) end end private def apply_scopes(scope, backends, locale, predicates) backends.inject(scope) { |r, b| b.apply_scope(r, predicates, locale) } end end end private_constant :QueryMethod, :VirtualRow 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 def order(opts, *rest) case opts when Symbol, String @klass.mobility_attribute?(opts) ? order({ opts => :asc }, *rest) : super when Hash i18n_keys, keys = opts.keys.partition(&@klass.method(:mobility_attribute?)) return super if i18n_keys.empty? base = keys.empty? ? self : super(opts.slice(keys)) i18n_keys.inject(base) do |query, key| backend_class = @klass.mobility_backend_class(key) dir, node = opts[key], backend_node(key) backend_class.apply_scope(query, node).order(node.send(dir.downcase)) end else super end end # Return backend node for attribute name. # @param [Symbol,String] name Name of attribute # @param [Symbol] locale Locale # @return [Arel::Node] Arel node for this attribute in given locale def backend_node(name, locale = Mobility.locale) @klass.mobility_backend_class(name)[name, locale] 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 IDENTITY = ->(x) { x }.freeze class << self def build(scope, where_opts, invert: false, &block) return yield unless Hash === where_opts opts = where_opts.with_indifferent_access locale = opts.delete(:locale) || Mobility.locale _build(scope, opts, locale, invert, &block) end private # Builds a translated relation for a given opts hash and optional # invert boolean. def _build(scope, opts, locale, invert) keys, predicates = opts.keys.map(&:to_s), [] query_map = scope.mobility_modules.inject(IDENTITY) do |qm, mod| i18n_keys = mod.names & keys next qm if i18n_keys.empty? mod_predicates = i18n_keys.map do |key| build_predicate(scope.backend_node(key.to_sym, locale), opts.delete(key)) end invert_predicates!(mod_predicates) if invert predicates += mod_predicates ->(r) { mod.backend_class.apply_scope(qm[r], mod_predicates, locale, invert: invert) } end return yield if query_map == IDENTITY relation = opts.empty? ? scope : yield(opts) query_map[relation.where(predicates.inject(&:and))] end def build_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 def invert_predicates!(predicates) predicates.map!(&method(:invert_predicate)) end # Adapted from AR::Relation::WhereClause#invert_predicate def invert_predicate(predicate) case predicate when ::Arel::Nodes::In ::Arel::Nodes::NotIn.new(predicate.left, predicate.right) when ::Arel::Nodes::Equality ::Arel::Nodes::NotEqual.new(predicate.left, predicate.right) else ::Arel::Nodes::Not.new(predicate) 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