diff --git a/lib/acts_as_taggable_on/taggable/core.rb b/lib/acts_as_taggable_on/taggable/core.rb index 6f02663bf..734d82aa1 100644 --- a/lib/acts_as_taggable_on/taggable/core.rb +++ b/lib/acts_as_taggable_on/taggable/core.rb @@ -1,3 +1,5 @@ +require_relative 'tagged_with_query' + module ActsAsTaggableOn::Taggable module Core def self.included(base) @@ -88,158 +90,13 @@ def tagged_with(tags, options = {}) return empty_result if tag_list.empty? - joins = [] - conditions = [] - having = [] - select_clause = [] - order_by = [] - - context = options.delete(:on) - owned_by = options.delete(:owned_by) - alias_base_name = undecorated_table_name.gsub('.', '_') - # FIXME use ActiveRecord's connection quote_column_name - quote = ActsAsTaggableOn::Utils.using_postgresql? ? '"' : '' - - if options.delete(:exclude) - if options.delete(:wild) - tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{ActsAsTaggableOn::Utils.like_operator} ? ESCAPE '!'", "%#{ActsAsTaggableOn::Utils.escape_like(t)}%"]) }.join(' OR ') - else - tags_conditions = tag_list.map { |t| sanitize_sql(["#{ActsAsTaggableOn::Tag.table_name}.name #{ActsAsTaggableOn::Utils.like_operator} ?", t]) }.join(' OR ') - end - - conditions << "#{table_name}.#{primary_key} NOT IN (SELECT #{ActsAsTaggableOn::Tagging.table_name}.taggable_id FROM #{ActsAsTaggableOn::Tagging.table_name} JOIN #{ActsAsTaggableOn::Tag.table_name} ON #{ActsAsTaggableOn::Tagging.table_name}.tag_id = #{ActsAsTaggableOn::Tag.table_name}.#{ActsAsTaggableOn::Tag.primary_key} AND (#{tags_conditions}) WHERE #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = #{self.connection.quote(base_class.name)})" - - if owned_by - joins << "JOIN #{ActsAsTaggableOn::Tagging.table_name}" + - " ON #{ActsAsTaggableOn::Tagging.table_name}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" + - " AND #{ActsAsTaggableOn::Tagging.table_name}.taggable_type = #{self.connection.quote(base_class.name)}" + - " AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_id = #{self.connection.quote(owned_by.id)}" + - " AND #{ActsAsTaggableOn::Tagging.table_name}.tagger_type = #{self.connection.quote(owned_by.class.base_class.to_s)}" - - joins << " AND " + sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at] - joins << " AND " + sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at] - end - - elsif options.delete(:any) - # get tags, drop out if nothing returned (we need at least one) - tags = if options.delete(:wild) - ActsAsTaggableOn::Tag.named_like_any(tag_list) - else - ActsAsTaggableOn::Tag.named_any(tag_list) - end - - return empty_result if tags.length == 0 - - # setup taggings alias so we can chain, ex: items_locations_taggings_awesome_cool_123 - # avoid ambiguous column name - taggings_context = context ? "_#{context}" : '' - - taggings_alias = adjust_taggings_alias( - "#{alias_base_name[0..4]}#{taggings_context[0..6]}_taggings_#{ActsAsTaggableOn::Utils.sha_prefix(tags.map(&:name).join('_'))}" - ) - - tagging_cond = "#{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" + - " WHERE #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" + - " AND #{taggings_alias}.taggable_type = #{self.connection.quote(base_class.name)}" - - tagging_cond << " AND " + sanitize_sql(["#{taggings_alias}.created_at >= ?", options.delete(:start_at)]) if options[:start_at] - tagging_cond << " AND " + sanitize_sql(["#{taggings_alias}.created_at <= ?", options.delete(:end_at)]) if options[:end_at] - - tagging_cond << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context - - # don't need to sanitize sql, map all ids and join with OR logic - tag_ids = tags.map { |t| self.connection.quote(t.id) }.join(', ') - tagging_cond << " AND #{taggings_alias}.tag_id in (#{tag_ids})" - select_clause << " #{table_name}.*" unless context and tag_types.one? - - if owned_by - tagging_cond << ' AND ' + - sanitize_sql([ - "#{taggings_alias}.tagger_id = ? AND #{taggings_alias}.tagger_type = ?", - owned_by.id, - owned_by.class.base_class.to_s - ]) - end - - conditions << "EXISTS (SELECT 1 FROM #{tagging_cond})" - if options.delete(:order_by_matching_tag_count) - order_by << "(SELECT count(*) FROM #{tagging_cond}) desc" - end - else - tags = ActsAsTaggableOn::Tag.named_any(tag_list) - - return empty_result unless tags.length == tag_list.length - - tags.each do |tag| - taggings_alias = adjust_taggings_alias("#{alias_base_name[0..11]}_taggings_#{ActsAsTaggableOn::Utils.sha_prefix(tag.name)}") - tagging_join = "JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" \ - " ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" + - " AND #{taggings_alias}.taggable_type = #{self.connection.quote(base_class.name)}" + - " AND #{taggings_alias}.tag_id = #{self.connection.quote(tag.id)}" - - tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.created_at >= ?", options.delete(:start_at)]) if options[:start_at] - tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.created_at <= ?", options.delete(:end_at)]) if options[:end_at] - - tagging_join << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context - - if owned_by - tagging_join << ' AND ' + - sanitize_sql([ - "#{taggings_alias}.tagger_id = ? AND #{taggings_alias}.tagger_type = ?", - owned_by.id, - owned_by.class.base_class.to_s - ]) - end - - joins << tagging_join - end - end - - group ||= [] # Rails interprets this as a no-op in the group() call below - if options.delete(:order_by_matching_tag_count) - select_clause << "#{table_name}.*, COUNT(#{taggings_alias}.tag_id) AS #{taggings_alias}_count" - group_columns = ActsAsTaggableOn::Utils.using_postgresql? ? grouped_column_names_for(self) : "#{table_name}.#{primary_key}" - group = group_columns - order_by << "#{taggings_alias}_count DESC" - - elsif options.delete(:match_all) - taggings_alias, _ = adjust_taggings_alias("#{alias_base_name}_taggings_group"), "#{alias_base_name}_tags_group" - joins << "LEFT OUTER JOIN #{ActsAsTaggableOn::Tagging.table_name} #{taggings_alias}" \ - " ON #{taggings_alias}.taggable_id = #{quote}#{table_name}#{quote}.#{primary_key}" \ - " AND #{taggings_alias}.taggable_type = #{self.connection.quote(base_class.name)}" - - joins << " AND " + sanitize_sql(["#{taggings_alias}.context = ?", context.to_s]) if context - joins << " AND " + sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at] - joins << " AND " + sanitize_sql(["#{ActsAsTaggableOn::Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at] - - group_columns = ActsAsTaggableOn::Utils.using_postgresql? ? grouped_column_names_for(self) : "#{table_name}.#{primary_key}" - group = group_columns - having = "COUNT(#{taggings_alias}.taggable_id) = #{tags.size}" - end - - order_by << options[:order] if options[:order].present? - - query = self - query = self.select(select_clause.join(',')) unless select_clause.empty? - query.joins(joins.join(' ')) - .where(conditions.join(' AND ')) - .group(group) - .having(having) - .order(order_by.join(', ')) - .readonly(false) + ::ActsAsTaggableOn::Taggable::TaggedWithQuery.build(self, ActsAsTaggableOn::Tag, ActsAsTaggableOn::Tagging, tag_list, options) end def is_taggable? true end - def adjust_taggings_alias(taggings_alias) - if taggings_alias.size > 75 - taggings_alias = 'taggings_alias_' + Digest::SHA1.hexdigest(taggings_alias) - end - taggings_alias - end - def taggable_mixin @taggable_mixin ||= Module.new end diff --git a/lib/acts_as_taggable_on/taggable/tagged_with_query.rb b/lib/acts_as_taggable_on/taggable/tagged_with_query.rb new file mode 100644 index 000000000..a2b2613c6 --- /dev/null +++ b/lib/acts_as_taggable_on/taggable/tagged_with_query.rb @@ -0,0 +1,16 @@ +require_relative 'tagged_with_query/query_base' +require_relative 'tagged_with_query/exclude_tags_query' +require_relative 'tagged_with_query/any_tags_query' +require_relative 'tagged_with_query/all_tags_query' + +module ActsAsTaggableOn::Taggable::TaggedWithQuery + def self.build(taggable_model, tag_model, tagging_model, tag_list, options) + if options[:exclude].present? + ExcludeTagsQuery.new(taggable_model, tag_model, tagging_model, tag_list, options).build + elsif options[:any].present? + AnyTagsQuery.new(taggable_model, tag_model, tagging_model, tag_list, options).build + else + AllTagsQuery.new(taggable_model, tag_model, tagging_model, tag_list, options).build + end + end +end diff --git a/lib/acts_as_taggable_on/taggable/tagged_with_query/all_tags_query.rb b/lib/acts_as_taggable_on/taggable/tagged_with_query/all_tags_query.rb new file mode 100644 index 000000000..ba5674c1a --- /dev/null +++ b/lib/acts_as_taggable_on/taggable/tagged_with_query/all_tags_query.rb @@ -0,0 +1,113 @@ +module ActsAsTaggableOn::Taggable::TaggedWithQuery + class AllTagsQuery < QueryBase + def build + taggable_model.joins(each_tag_in_list) + .group(by_taggable) + .having(tags_that_matches_count) + .order(order_conditions) + .readonly(false) + end + + private + + def each_tag_in_list + arel_join = taggable_arel_table + + tag_list.each do |tag| + tagging_alias = tagging_arel_table.alias(tagging_alias(tag)) + arel_join = arel_join + .join(tagging_alias) + .on(on_conditions(tag, tagging_alias)) + end + + if options[:match_all].present? + arel_join = arel_join + .join(tagging_arel_table, Arel::Nodes::OuterJoin) + .on( + match_all_on_conditions + ) + end + + return arel_join.join_sources + end + + def on_conditions(tag, tagging_alias) + on_condition = tagging_alias[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key]) + .and(tagging_alias[:taggable_type].eq(taggable_model.base_class.name)) + .and( + tagging_alias[:tag_id].in( + tag_arel_table.project(tag_arel_table[:id]).where(tag_match_type(tag)) + ) + ) + + if options[:start_at].present? + on_condition = on_condition.and(tagging_alias[:created_at].gteq(options[:start_at])) + end + + if options[:end_at].present? + on_condition = on_condition.and(tagging_alias[:created_at].lteq(options[:end_at])) + end + + if options[:on].present? + on_condition = on_condition.and(tagging_alias[:context].lteq(options[:on])) + end + + if (owner = options[:owned_by]).present? + owner_table = owner.class.base_class.arel_table + + on_condition = on_condition.and(tagging_alias[:tagger_id].eq(owner.id)) + .and(tagging_alias[:tagger_type].eq(owner.class.base_class.to_s)) + end + + on_condition + end + + def match_all_on_conditions + on_condition = tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key]) + .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name)) + + if options[:start_at].present? + on_condition = on_condition.and(tagging_arel_table[:created_at].gteq(options[:start_at])) + end + + if options[:end_at].present? + on_condition = on_condition.and(tagging_arel_table[:created_at].lteq(options[:end_at])) + end + + if options[:on].present? + on_condition = on_condition.and(tagging_arel_table[:context].lteq(options[:on])) + end + + on_condition + end + + def by_taggable + return [] unless options[:match_all].present? + + taggable_arel_table[taggable_model.primary_key] + end + + def tags_that_matches_count + return [] unless options[:match_all].present? + + taggable_model.find_by_sql(tag_arel_table.project(Arel.star.count).where(tags_match_type).to_sql) + + tagging_arel_table[:taggable_id].count.eq( + tag_arel_table.project(Arel.star.count).where(tags_match_type) + ) + end + + def order_conditions + order_by = [] + order_by << tagging_arel_table.project(tagging_arel_table[Arel.star].count.as('taggings_count')).order('taggings_count DESC').to_sql if options[:order_by_matching_tag_count].present? && options[:match_all].blank? + + order_by << options[:order] if options[:order].present? + order_by.join(', ') + end + + def tagging_alias(tag) + alias_base_name = taggable_model.base_class.name.downcase + adjust_taggings_alias("#{alias_base_name[0..11]}_taggings_#{ActsAsTaggableOn::Utils.sha_prefix(tag)}") + end + end +end diff --git a/lib/acts_as_taggable_on/taggable/tagged_with_query/any_tags_query.rb b/lib/acts_as_taggable_on/taggable/tagged_with_query/any_tags_query.rb new file mode 100644 index 000000000..88d5de4c1 --- /dev/null +++ b/lib/acts_as_taggable_on/taggable/tagged_with_query/any_tags_query.rb @@ -0,0 +1,75 @@ +module ActsAsTaggableOn::Taggable::TaggedWithQuery + class AnyTagsQuery < QueryBase + def build + taggable_model.select(all_fields) + .where(model_has_at_least_one_tag) + .order(order_conditions) + .readonly(false) + end + + private + + def all_fields + taggable_arel_table[Arel.star] + end + + def model_has_at_least_one_tag + tagging_alias = tagging_arel_table.alias(alias_name(tag_list)) + + + tagging_arel_table.project(Arel.star).where(at_least_one_tag).exists + end + + def at_least_one_tag + exists_contition = tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key]) + .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name)) + .and( + tagging_arel_table[:tag_id].in( + tag_arel_table.project(tag_arel_table[:id]).where(tags_match_type) + ) + ) + + if options[:start_at].present? + exists_contition = exists_contition.and(tagging_arel_table[:created_at].gteq(options[:start_at])) + end + + if options[:end_at].present? + exists_contition = exists_contition.and(tagging_arel_table[:created_at].lteq(options[:end_at])) + end + + if options[:on].present? + exists_contition = exists_contition.and(tagging_arel_table[:context].lteq(options[:on])) + end + + if (owner = options[:owned_by]).present? + owner_table = owner.class.base_class.arel_table + + exists_contition = exists_contition.and(tagging_arel_table[:tagger_id].eq(owner.id)) + .and(tagging_arel_table[:tagger_type].eq(owner.class.base_class.to_s)) + end + + exists_contition + end + + def order_conditions + order_by = [] + if options[:order_by_matching_tag_count].present? + order_by << "(SELECT count(*) FROM #{tagging_model.table_name} WHERE #{at_least_one_tag.to_sql}) desc" + end + + order_by << options[:order] if options[:order].present? + order_by.join(', ') + end + + def alias_name(tag_list) + alias_base_name = taggable_model.base_class.name.downcase + taggings_context = options[:on] ? "_#{options[:on]}" : '' + + taggings_alias = adjust_taggings_alias( + "#{alias_base_name[0..4]}#{taggings_context[0..6]}_taggings_#{ActsAsTaggableOn::Utils.sha_prefix(tag_list.join('_'))}" + ) + + taggings_alias + end + end +end diff --git a/lib/acts_as_taggable_on/taggable/tagged_with_query/exclude_tags_query.rb b/lib/acts_as_taggable_on/taggable/tagged_with_query/exclude_tags_query.rb new file mode 100644 index 000000000..26a67fb78 --- /dev/null +++ b/lib/acts_as_taggable_on/taggable/tagged_with_query/exclude_tags_query.rb @@ -0,0 +1,82 @@ +module ActsAsTaggableOn::Taggable::TaggedWithQuery + class ExcludeTagsQuery < QueryBase + def build + taggable_model.joins(owning_to_tagger) + .where(tags_not_in_list) + .having(tags_that_matches_count) + .readonly(false) + end + + private + + def tags_not_in_list + return taggable_arel_table[:id].not_in( + tagging_arel_table + .project(tagging_arel_table[:taggable_id]) + .join(tag_arel_table) + .on( + tagging_arel_table[:tag_id].eq(tag_arel_table[:id]) + .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name)) + .and(tags_match_type) + ) + ) + + # FIXME: missing time scope, this is also missing in the original implementation + end + + + def owning_to_tagger + return [] unless options[:owned_by].present? + + owner = options[:owned_by] + + arel_join = taggable_arel_table + .join(tagging_arel_table) + .on( + tagging_arel_table[:tagger_id].eq(owner.id) + .and(tagging_arel_table[:tagger_type].eq(owner.class.base_class.to_s)) + .and(tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key])) + .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name)) + ) + + if options[:match_all].present? + arel_join = arel_join + .join(tagging_arel_table, Arel::Nodes::OuterJoin) + .on( + match_all_on_conditions + ) + end + + return arel_join.join_sources + end + + def match_all_on_conditions + on_condition = tagging_arel_table[:taggable_id].eq(taggable_arel_table[taggable_model.primary_key]) + .and(tagging_arel_table[:taggable_type].eq(taggable_model.base_class.name)) + + if options[:start_at].present? + on_condition = on_condition.and(tagging_arel_table[:created_at].gteq(options[:start_at])) + end + + if options[:end_at].present? + on_condition = on_condition.and(tagging_arel_table[:created_at].lteq(options[:end_at])) + end + + if options[:on].present? + on_condition = on_condition.and(tagging_arel_table[:context].lteq(options[:on])) + end + + on_condition + end + + def tags_that_matches_count + return [] unless options[:match_all].present? + + taggable_model.find_by_sql(tag_arel_table.project(Arel.star.count).where(tags_match_type).to_sql) + + tagging_arel_table[:taggable_id].count.eq( + tag_arel_table.project(Arel.star.count).where(tags_match_type) + ) + end + end +end diff --git a/lib/acts_as_taggable_on/taggable/tagged_with_query/query_base.rb b/lib/acts_as_taggable_on/taggable/tagged_with_query/query_base.rb new file mode 100644 index 000000000..011ae51e8 --- /dev/null +++ b/lib/acts_as_taggable_on/taggable/tagged_with_query/query_base.rb @@ -0,0 +1,61 @@ +module ActsAsTaggableOn::Taggable::TaggedWithQuery + class QueryBase + def initialize(taggable_model, tag_model, tagging_model, tag_list, options) + @taggable_model = taggable_model + @tag_model = tag_model + @tagging_model = tagging_model + @tag_list = tag_list + @options = options + end + + private + + attr_reader :taggable_model, :tag_model, :tagging_model, :tag_list, :options + + def taggable_arel_table + @taggable_arel_table ||= taggable_model.arel_table + end + + def tag_arel_table + @tag_arel_table ||= tag_model.arel_table + end + + def tagging_arel_table + @tagging_arel_table ||=tagging_model.arel_table + end + + def tag_match_type(tag) + matches_attribute = tag_arel_table[:name] + matches_attribute = matches_attribute.lower unless ActsAsTaggableOn.strict_case_match + + if options[:wild].present? + tag_arel_table[:name].matches("%#{escaped_tag(tag)}%", "!") + else + tag_arel_table[:name].matches(escaped_tag(tag), "!") + end + end + + def tags_match_type + matches_attribute = tag_arel_table[:name] + matches_attribute = matches_attribute.lower unless ActsAsTaggableOn.strict_case_match + + if options[:wild].present? + matches_attribute.matches_any(tag_list.map{|tag| "%#{escaped_tag(tag)}%"}, "!") + else + matches_attribute.matches_any(tag_list.map{|tag| "#{escaped_tag(tag)}"}, "!") + end + end + + def escaped_tag(tag) + tag = tag.downcase unless ActsAsTaggableOn.strict_case_match + tag.gsub(/[!%_]/) { |x| '!' + x } + end + + def adjust_taggings_alias(taggings_alias) + if taggings_alias.size > 75 + taggings_alias = 'taggings_alias_' + Digest::SHA1.hexdigest(taggings_alias) + end + taggings_alias + end + end +end diff --git a/lib/acts_as_taggable_on/tagging.rb b/lib/acts_as_taggable_on/tagging.rb index fcfda57f9..783c45825 100644 --- a/lib/acts_as_taggable_on/tagging.rb +++ b/lib/acts_as_taggable_on/tagging.rb @@ -1,5 +1,6 @@ module ActsAsTaggableOn class Tagging < ::ActiveRecord::Base #:nodoc: + DEFAULT_CONTEXT = 'tags' belongs_to :tag, class_name: '::ActsAsTaggableOn::Tag', counter_cache: ActsAsTaggableOn.tags_counter belongs_to :taggable, polymorphic: true @@ -8,8 +9,8 @@ class Tagging < ::ActiveRecord::Base #:nodoc: scope :owned_by, ->(owner) { where(tagger: owner) } scope :not_owned, -> { where(tagger_id: nil, tagger_type: nil) } - scope :by_contexts, ->(contexts) { where(context: (contexts || 'tags')) } - scope :by_context, ->(context = 'tags') { by_contexts(context.to_s) } + scope :by_contexts, ->(contexts) { where(context: (contexts || DEFAULT_CONTEXT)) } + scope :by_context, ->(context = DEFAULT_CONTEXT) { by_contexts(context.to_s) } validates_presence_of :context validates_presence_of :tag_id diff --git a/spec/acts_as_taggable_on/taggable_spec.rb b/spec/acts_as_taggable_on/taggable_spec.rb index 414b6d2f1..a2835e0ca 100644 --- a/spec/acts_as_taggable_on/taggable_spec.rb +++ b/spec/acts_as_taggable_on/taggable_spec.rb @@ -247,7 +247,7 @@ expect(TaggableModel.tagged_with("ruby", :start_at => today, :end_at => tomorrow).count).to eq(1) end - it "shouldn't be able to find a tag outside date range" do + it "shouldn't be able to find a tag outside date range" do @taggable.skill_list = "ruby" @taggable.save