From 5ef49692e08fdb57e89042810cda9497c1f3be04 Mon Sep 17 00:00:00 2001
From: Neil Lalonde <neillalonde@gmail.com>
Date: Wed, 16 Oct 2019 14:27:30 -0400
Subject: [PATCH] FIX: tag cannot be used if it belongs to two tag groups with
 parent tag

If two tag groups exist with a mandatory parent tag, and one tag is
added to both tag groups, then the tag couldn't be used on any topics.
---
 lib/discourse_tagging.rb                  | 35 ++++++++++++++++++-----
 spec/components/discourse_tagging_spec.rb | 15 +++++++++-
 spec/integration/category_tag_spec.rb     | 17 +++++++++++
 3 files changed, 59 insertions(+), 8 deletions(-)

diff --git a/lib/discourse_tagging.rb b/lib/discourse_tagging.rb
index 0213eb73fce..e8bb6b96ecc 100644
--- a/lib/discourse_tagging.rb
+++ b/lib/discourse_tagging.rb
@@ -65,16 +65,27 @@ module DiscourseTagging
         end
 
         # add missing mandatory parent tags
-        parent_tags = TagGroup.includes(:parent_tag).where("tag_groups.id IN (
-          SELECT tg.id
+        tag_ids = tags.map(&:id)
+
+        parent_tags_map = DB.query("
+          SELECT tgm.tag_id, tg.parent_tag_id
             FROM tag_groups tg
       INNER JOIN tag_group_memberships tgm
               ON tgm.tag_group_id = tg.id
            WHERE tg.parent_tag_id IS NOT NULL
-             AND tgm.tag_id IN (?))", tags.map(&:id)).map(&:parent_tag)
+             AND tgm.tag_id IN (?)
+        ", tag_ids).inject({}) do |h, v|
+          h[v.tag_id] ||= []
+          h[v.tag_id] << v.parent_tag_id
+          h
+        end
 
-        parent_tags.reject { |t| tag_names.include?(t.name) }.each do |tag|
-          tags << tag
+        missing_parent_tag_ids = parent_tags_map.map do |_, parent_tag_ids|
+          (tag_ids & parent_tag_ids).size == 0 ? parent_tag_ids.first : nil
+        end.compact.uniq
+
+        unless missing_parent_tag_ids.empty?
+          tags = tags + Tag.where(id: missing_parent_tag_ids).all
         end
 
         # validate minimum required tags for a category
@@ -187,8 +198,18 @@ module DiscourseTagging
         exclude_group_ids = one_per_topic_group_ids(selected_tag_ids)
 
         if exclude_group_ids.empty?
-          sql = "tags.id NOT IN (#{TAG_GROUP_TAG_IDS_SQL} WHERE tg.parent_tag_id NOT IN (?))"
-          query = query.where(sql, selected_tag_ids)
+          # tags that don't belong to groups that require a parent tag,
+          # and tags that belong to groups with parent tag selected
+          query = query.where(<<~SQL, selected_tag_ids, selected_tag_ids)
+            tags.id NOT IN (
+              #{TAG_GROUP_TAG_IDS_SQL}
+              WHERE tg.parent_tag_id NOT IN (?)
+            )
+            OR tags.id IN (
+              #{TAG_GROUP_TAG_IDS_SQL}
+              WHERE tg.parent_tag_id IN (?)
+            )
+          SQL
         else
           # It's possible that the selected tags violate some one-tag-per-group restrictions,
           # so filter them out by picking one from each group.
diff --git a/spec/components/discourse_tagging_spec.rb b/spec/components/discourse_tagging_spec.rb
index 3dfa17662cb..d269ffd5eca 100644
--- a/spec/components/discourse_tagging_spec.rb
+++ b/spec/components/discourse_tagging_spec.rb
@@ -194,9 +194,9 @@ describe DiscourseTagging do
     context 'tag group with parent tag' do
       let(:topic) { Fabricate(:topic, user: user) }
       let(:post) { Fabricate(:post, user: user, topic: topic, post_number: 1) }
+      let(:tag_group) { Fabricate(:tag_group, parent_tag_id: tag1.id) }
 
       before do
-        tag_group = Fabricate(:tag_group, parent_tag_id: tag1.id)
         tag_group.tags = [tag3]
       end
 
@@ -222,6 +222,19 @@ describe DiscourseTagging do
           *[tag1, tag2, tag3, parent_tag].map(&:name)
         )
       end
+
+      it "adds only the necessary parent tags" do
+        common = Fabricate(:tag, name: 'common')
+        tag_group.tags = [tag3, common]
+
+        parent_tag = Fabricate(:tag, name: 'parent')
+        tag_group2 = Fabricate(:tag_group, parent_tag_id: parent_tag.id)
+        tag_group2.tags = [tag2, common]
+
+        valid = DiscourseTagging.tag_topic_by_names(topic, Guardian.new(user), [parent_tag.name, common.name])
+        expect(valid).to eq(true)
+        expect(topic.reload.tags.map(&:name)).to contain_exactly(*[parent_tag, common].map(&:name))
+      end
     end
   end
 
diff --git a/spec/integration/category_tag_spec.rb b/spec/integration/category_tag_spec.rb
index b20829778b4..9fddfb35ccd 100644
--- a/spec/integration/category_tag_spec.rb
+++ b/spec/integration/category_tag_spec.rb
@@ -212,6 +212,23 @@ describe "category tag restrictions" do
       expect(filter_allowed_tags(for_topic: true, selected_tags: [tag1.name, tag3.name])).to contain_exactly(tag1, tag2, tag3, tag4)
     end
 
+    it "filter_allowed_tags returns tags common to more than one tag group with parent tag" do
+      common = Fabricate(:tag, name: 'common')
+      tag_group = Fabricate(:tag_group, parent_tag_id: tag1.id)
+      tag_group.tags = [tag2, common]
+      tag_group = Fabricate(:tag_group, parent_tag_id: tag3.id)
+
+      tag_group.tags = [tag4]
+      expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag1, tag3)
+      expect(filter_allowed_tags(for_input: true, selected_tags: [tag1.name])).to contain_exactly(tag2, tag3, common)
+      expect(filter_allowed_tags(for_input: true, selected_tags: [tag3.name])).to contain_exactly(tag4, tag1)
+
+      tag_group.tags = [tag4, common]
+      expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag1, tag3)
+      expect(filter_allowed_tags(for_input: true, selected_tags: [tag1.name])).to contain_exactly(tag2, tag3, common)
+      expect(filter_allowed_tags(for_input: true, selected_tags: [tag3.name])).to contain_exactly(tag4, tag1, common)
+    end
+
     context "and category restrictions" do
       fab!(:car_category)    { Fabricate(:category) }
       fab!(:other_category)  { Fabricate(:category) }