# frozen-string-literal: true require "active_record/relation" 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 extend Plugin requires :query, include: false ATTRIBUTE_ALIAS_PREFIX = "__mobility_" ATTRIBUTE_ALIAS = "#{ATTRIBUTE_ALIAS_PREFIX}%s_%s__" included_hook do |klass, backend_class| plugin = self if options[:query] raise MissingBackend, "backend required for Query plugin" unless backend_class klass.class_eval do extend QueryMethod extend FindByMethods.new(*plugin.names) singleton_class.define_method(plugin.query_method) do |locale: Mobility.locale, &block| Query.build_query(self, locale, &block) end end backend_class.include BackendMethods end end class << self def attribute_alias(attribute, locale = Mobility.locale) ATTRIBUTE_ALIAS % [attribute, ::Mobility.normalize_locale(locale)] end def build_query(klass, locale = Mobility.locale, &block) if block_given? VirtualRow.build_query(klass, locale, &block) else klass.all.extending(QueryExtension) end end end module BackendMethods # @note We use +instance_variable_get+ here to get the +AttributeSet+ # rather than the hash of attributes. Getting the full hash of # attributes is a performance hit and better to avoid if unnecessary. # TODO: Improve this. def read(locale, **) if model.instance_variable_defined?(:@attributes) && (model_attributes = model.instance_variable_get(:@attributes)).key?(alias_ = Query.attribute_alias(attribute, locale)) model_attributes[alias_].value else super end end end module QueryMethod def __mobility_query_scope__(locale: Mobility.locale, &block) warn '__mobility_query_scope__ is an internal method and will be deprecated in the next release.' Query.build_query(self, locale, &block) end end # Creates 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, :locales def initialize(klass, global_locale) @klass, @global_locale, @locales, @backends = klass, global_locale, [], [] end def method_missing(m, *args) if @klass.mobility_attribute?(m) @backends |= [@klass.mobility_backend_class(m)] ::Mobility.validate_locale!(args[0]) if args[0] locale = args[0] || @global_locale @locales |= [locale] @klass.mobility_backend_class(m).build_node(m, locale) elsif @klass.column_names.include?(m.to_s) @klass.arel_table[m] else super end end class << self def build_query(klass, locale, &block) ::Mobility.validate_locale!(locale) 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, row.locales, predicates).merge(query) else apply_scopes(klass.all, row.backends, row.locales, query).where(query) end end private def apply_scopes(scope, backends, locales, predicates) backends.inject(scope) do |scope_, b| locales.inject(scope_) do |r, locale| b.apply_scope(r, predicates, locale) end end end end end private_constant :QueryMethod 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) return super unless klass.respond_to?(:mobility_attribute?) 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 %w[pluck group select].each do |method_name| define_method method_name do |*attrs, &block| return super(*attrs, &block) if (method_name == 'select' && block.present?) return super(*attrs, &block) unless klass.respond_to?(:mobility_attribute?) return super(*attrs, &block) unless attrs.any?(&klass.method(:mobility_attribute?)) keys = attrs.dup base = keys.each_with_index.inject(self) do |query, (key, index)| next query unless klass.mobility_attribute?(key) keys[index] = backend_node(key) if method_name == "select" keys[index] = keys[index] .as(::Mobility::Plugins::ActiveRecord::Query.attribute_alias(key.to_s)) end klass.mobility_backend_class(key).apply_scope(query, backend_node(key)) end base.public_send(method_name, *keys, &block) end end if ::ActiveRecord::VERSION::MAJOR >= 8 # Fix for https://github.com/shioyama/mobility/pull/654#issuecomment-2503479112 # TODO: Make this better def select_for_count return super unless klass.respond_to?(:mobility_attribute?) if select_values.any? { |value| value.right.start_with?(ATTRIBUTE_ALIAS_PREFIX) } filtered_select_values = select_values.map do |value| value.right.start_with?(ATTRIBUTE_ALIAS_PREFIX) ? value.left : value end # Copied from lib/active_record/relation/calculations.rb with_connection do |conn| arel_columns(filtered_select_values).map { |column| conn.visitor.compile(column) }.join(", ") end else super end 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) return yield if (mods = translation_modules(scope)).empty? keys, predicates = opts.keys.map(&:to_s), [] used_keys = [] query_map = mods.inject(IDENTITY) do |qm, mod| 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 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 translation_modules(scope) scope.model.ancestors.grep(::Mobility::Translations) 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 :FindByMethods end class MissingBackend < Mobility::Error; end end register_plugin(:active_record_query, ActiveRecord::Query) end end