Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tagged with rewrite #829

Merged
merged 10 commits into from
May 16, 2017
149 changes: 3 additions & 146 deletions lib/acts_as_taggable_on/taggable/core.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require_relative 'tagged_with_query'

module ActsAsTaggableOn::Taggable
module Core
def self.included(base)
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions lib/acts_as_taggable_on/taggable/tagged_with_query.rb
Original file line number Diff line number Diff line change
@@ -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
113 changes: 113 additions & 0 deletions lib/acts_as_taggable_on/taggable/tagged_with_query/all_tags_query.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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