Skip to content

Commit

Permalink
Tagged with rewrite (#829)
Browse files Browse the repository at this point in the history
  • Loading branch information
rbritom authored and seuros committed May 16, 2017
1 parent f5222d8 commit 64ed839
Show file tree
Hide file tree
Showing 8 changed files with 354 additions and 149 deletions.
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
Loading

0 comments on commit 64ed839

Please sign in to comment.