diff --git a/app/models/article.rb b/app/models/article.rb
index 7885169fe..a87702346 100644
--- a/app/models/article.rb
+++ b/app/models/article.rb
@@ -2,7 +2,7 @@ class Article < ApplicationRecord
include CloudinaryHelper
include ActionView::Helpers
include Reactable
- include TagListValidateable
+ include Taggable
include UserSubscriptionSourceable
include PgSearch::Model
@@ -288,47 +288,6 @@ class Article < ApplicationRecord
.tagged_with(tag_name)
}
- scope :cached_tagged_with, lambda { |tag|
- case tag
- when String, Symbol
- # In Postgres regexes, the [[:<:]] and [[:>:]] are equivalent to "start of
- # word" and "end of word", respectively. They're similar to `\b` in Perl-
- # compatible regexes (PCRE), but that matches at either end of a word.
- # They're more comparable to how vim's `\<` and `\>` work.
- where("cached_tag_list ~ ?", "[[:<:]]#{tag}[[:>:]]")
- when Array
- tag.reduce(self) { |acc, elem| acc.cached_tagged_with(elem) }
- when Tag
- cached_tagged_with(tag.name)
- else
- raise TypeError, "Cannot search tags for: #{tag.inspect}"
- end
- }
-
- scope :cached_tagged_with_any, lambda { |tags|
- case tags
- when String, Symbol
- cached_tagged_with(tags)
- when Array
- tags
- .map { |tag| cached_tagged_with(tag) }
- .reduce { |acc, elem| acc.or(elem) }
- when Tag
- cached_tagged_with(tags.name)
- else
- raise TypeError, "Cannot search tags for: #{tags.inspect}"
- end
- }
-
- # We usually try to avoid using Arel directly like this. However, none of the more
- # straight-forward ways of negating the above scope worked:
- # 1. A subquery doesn't work because we're not dealing with a simple NOT IN scenario.
- # 2. where.not(cached_tagged_with_any(tags).where_values_hash) doesn't work because where_values_hash
- # only works for simple conditions and returns an empty hash in this case.
- scope :not_cached_tagged_with_any, lambda { |tags|
- where(cached_tagged_with_any(tags).arel.constraints.reduce(:or).not)
- }
-
scope :active_help, lambda {
stories = published.cached_tagged_with("help").order(created_at: :desc)
diff --git a/app/models/concerns/tag_list_validateable.rb b/app/models/concerns/tag_list_validateable.rb
deleted file mode 100644
index 433bee608..000000000
--- a/app/models/concerns/tag_list_validateable.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-module TagListValidateable
- extend ActiveSupport::Concern
- # we check tags names aren't too long and don't contain non alphabet characters
- def validate_tag_name(tag_list)
- tag_list.each do |tag|
- new_tag = Tag.new(name: tag)
- new_tag.validate_name
- new_tag.errors.messages[:name].each { |message| errors.add(:tag, "\"#{tag}\" #{message}") }
- end
- end
-end
diff --git a/app/models/concerns/taggable.rb b/app/models/concerns/taggable.rb
new file mode 100644
index 000000000..8f2a18a3e
--- /dev/null
+++ b/app/models/concerns/taggable.rb
@@ -0,0 +1,57 @@
+module Taggable
+ extend ActiveSupport::Concern
+
+ # rubocop:disable Metrics/BlockLength(RuboCop)
+ included do
+ scope :cached_tagged_with, lambda { |tag|
+ case tag
+ when String, Symbol
+ # In Postgres regexes, the [[:<:]] and [[:>:]] are equivalent to "start of
+ # word" and "end of word", respectively. They're similar to `\b` in Perl-
+ # compatible regexes (PCRE), but that matches at either end of a word.
+ # They're more comparable to how vim's `\<` and `\>` work.
+ where("cached_tag_list ~ ?", "[[:<:]]#{tag}[[:>:]]")
+ when Array
+ tag.reduce(self) { |acc, elem| acc.cached_tagged_with(elem) }
+ when Tag
+ cached_tagged_with(tag.name)
+ else
+ raise TypeError, "Cannot search tags for: #{tag.inspect}"
+ end
+ }
+
+ scope :cached_tagged_with_any, lambda { |tags|
+ case tags
+ when String, Symbol
+ cached_tagged_with(tags)
+ when Array
+ tags
+ .map { |tag| cached_tagged_with(tag) }
+ .reduce { |acc, elem| acc.or(elem) }
+ when Tag
+ cached_tagged_with(tags.name)
+ else
+ raise TypeError, "Cannot search tags for: #{tags.inspect}"
+ end
+ }
+
+ # We usually try to avoid using Arel directly like this. However, none of the more
+ # straight-forward ways of negating the above scope worked:
+ # 1. A subquery doesn't work because we're not dealing with a simple NOT IN scenario.
+ # 2. where.not(cached_tagged_with_any(tags).where_values_hash) doesn't work because where_values_hash
+ # only works for simple conditions and returns an empty hash in this case.
+ scope :not_cached_tagged_with_any, lambda { |tags|
+ where(cached_tagged_with_any(tags).arel.constraints.reduce(:or).not)
+ }
+ end
+ # rubocop:enable Metrics/BlockLength(RuboCop)
+
+ # we check tags names aren't too long and don't contain non alphabet characters
+ def validate_tag_name(tag_list)
+ tag_list.each do |tag|
+ new_tag = Tag.new(name: tag)
+ new_tag.validate_name
+ new_tag.errors.messages[:name].each { |message| errors.add(:tag, "\"#{tag}\" #{message}") }
+ end
+ end
+end
diff --git a/app/models/display_ad.rb b/app/models/display_ad.rb
index 8ce62b302..f047958b2 100644
--- a/app/models/display_ad.rb
+++ b/app/models/display_ad.rb
@@ -1,5 +1,5 @@
class DisplayAd < ApplicationRecord
- include TagListValidateable
+ include Taggable
acts_as_taggable_on :tags
resourcify
@@ -32,9 +32,20 @@ class DisplayAd < ApplicationRecord
search: "%#{term}%"
}
- def self.for_display(area, user_signed_in)
+ def self.for_display(area, user_signed_in, article_tags = [])
relation = approved_and_published.where(placement_area: area).order(success_rate: :desc)
+ if article_tags.any?
+ display_ads_with_no_tags = relation.where(cached_tag_list: "")
+ display_ads_with_targeted_article_tags = relation.cached_tagged_with_any(article_tags)
+
+ relation = display_ads_with_no_tags.or(display_ads_with_targeted_article_tags)
+ end
+
+ if article_tags.blank?
+ relation = relation.where(cached_tag_list: "")
+ end
+
relation = if user_signed_in
relation.where(display_to: %w[all logged_in])
else
diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb
index 8edec99de..2f2d0ef96 100644
--- a/app/views/articles/show.html.erb
+++ b/app/views/articles/show.html.erb
@@ -191,7 +191,7 @@
<% cache("article-bottom-content-#{@article.id}-#{user_signed_in?}-#{(@organization || @user).latest_article_updated_at}", expires_in: 18.hours) do %>
- <% @display_ad = DisplayAd.for_display("post_comments", user_signed_in?) %>
+ <% @display_ad = DisplayAd.for_display("post_comments", user_signed_in?, @article.decorate.cached_tag_list_array) %>
<% if @display_ad %>
+ # We need a GIN index on `cached_tag_list` to speed up `LIKE` queries
+ add_index :display_ads, :cached_tag_list, using: :gin, opclass: :gin_trgm_ops, algorithm: :concurrently
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 9c80404da..494444375 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.0].define(version: 2022_10_20_120850) do
+ActiveRecord::Schema[7.0].define(version: 2022_10_31_133328) do
# These are extensions that must be enabled in order to support this database
enable_extension "citext"
enable_extension "pg_stat_statements"
@@ -456,6 +456,7 @@ ActiveRecord::Schema[7.0].define(version: 2022_10_20_120850) do
create_table "display_ads", force: :cascade do |t|
t.boolean "approved", default: false
t.text "body_markdown"
+ t.string "cached_tag_list"
t.integer "clicks_count", default: 0
t.datetime "created_at", precision: nil, null: false
t.integer "display_to", default: 0, null: false
@@ -467,6 +468,7 @@ ActiveRecord::Schema[7.0].define(version: 2022_10_20_120850) do
t.boolean "published", default: false
t.float "success_rate", default: 0.0
t.datetime "updated_at", precision: nil, null: false
+ t.index ["cached_tag_list"], name: "index_display_ads_on_cached_tag_list", opclass: :gin_trgm_ops, using: :gin
end
create_table "email_authorizations", force: :cascade do |t|
diff --git a/spec/factories/articles.rb b/spec/factories/articles.rb
index 569641bef..0f2ed014d 100644
--- a/spec/factories/articles.rb
+++ b/spec/factories/articles.rb
@@ -21,6 +21,7 @@ FactoryBot.define do
with_collection { nil }
past_published_at { Time.current }
end
+
co_author_ids { [] }
association :user, factory: :user, strategy: :create
description { Faker::Hipster.paragraph(sentence_count: 1)[0..100] }
@@ -29,13 +30,22 @@ FactoryBot.define do
URL.url(ActionController::Base.helpers.asset_path("#{rand(1..40)}.png"))
end
end
+
experience_level_rating { rand(4..6) }
+ # The tags property in the markdown is a bit of a hack, and this entire factory needs refactoring.
+ # In the Tagglable spec we want to extract some common scopes from the article and display ad
+ # models and test them, hence we want to pass through the tag_list property.
+ # However, the body_markdown caters for the way that we associate tags for the v1 editor.
+ # Hence, in this test we default to the transient with_tags being set to true, but if we pass a tag_list through
+ # then we're making the assumption that it is the v2 editor and we do not want the tags on the body markdown.
+ # Ideally, we want to create a completely different body_markdown without the frontmatter depending on the version
+ # of the editor since we pass through different JSON based on the editor.
body_markdown do
<<~HEREDOC
---
title: #{title if with_title}
published: #{published}
- tags: #{tags if with_tags}
+ #{"tags: #{tags}" if with_tags && tag_list.blank?}
date: #{date if with_date}
series: #{with_collection&.slug}
canonical_url: #{canonical_url if with_canonical_url}
diff --git a/spec/models/article_spec.rb b/spec/models/article_spec.rb
index d472dfe59..0b956d314 100644
--- a/spec/models/article_spec.rb
+++ b/spec/models/article_spec.rb
@@ -12,6 +12,7 @@ RSpec.describe Article, type: :model do
include_examples "#sync_reactions_count", :article
it_behaves_like "UserSubscriptionSourceable"
+ it_behaves_like "Taggable"
describe "validations" do
it { is_expected.to belong_to(:collection).optional }
@@ -987,210 +988,6 @@ RSpec.describe Article, type: :model do
end
end
- describe ".cached_tagged_with" do
- it "can search for a single tag" do
- included = create(:article, tags: "includeme")
- excluded = create(:article, tags: "lol, nope")
-
- articles = described_class.cached_tagged_with("includeme")
-
- expect(articles).to include included
- expect(articles).not_to include excluded
- expect(articles.to_a).to eq described_class.tagged_with("includeme").to_a
- end
-
- it "can search for a single tag when given a symbol" do
- included = create(:article, tags: "includeme")
- excluded = create(:article, tags: "lol, nope")
-
- articles = described_class.cached_tagged_with(:includeme)
-
- expect(articles).to include(included)
- expect(articles).not_to include(excluded)
- expect(articles.to_a).to eq(described_class.tagged_with("includeme").to_a)
- end
-
- it "can search for a single tag when given a Tag object" do
- included = create(:article, tags: "includeme")
- excluded = create(:article, tags: "lol, nope")
-
- tag = Tag.find_by(name: :includeme)
-
- articles = described_class.cached_tagged_with(tag)
-
- expect(articles).to include included
- expect(articles).not_to include excluded
- expect(articles.to_a).to eq described_class.tagged_with("includeme").to_a
- end
-
- it "can search among multiple tags" do
- included = [
- create(:article, tags: "omg, wtf"),
- create(:article, tags: "omg, lol"),
- ]
- excluded = create(:article, tags: "nope, excluded")
-
- articles = described_class.cached_tagged_with("omg")
-
- expect(articles).to include(*included)
- expect(articles).not_to include excluded
- expect(articles.to_a).to include(*described_class.tagged_with("omg").to_a)
- end
-
- it "can search for multiple tags" do
- included = create(:article, tags: "includeme, please, lol")
- excluded_partial_match = create(:article, tags: "excluded, please")
- excluded_no_match = create(:article, tags: "excluded, omg")
-
- articles = described_class.cached_tagged_with(%w[includeme please])
-
- expect(articles).to include included
- expect(articles).not_to include excluded_partial_match
- expect(articles).not_to include excluded_no_match
- expect(articles.to_a).to eq described_class.tagged_with(%w[includeme please]).to_a
- end
-
- it "can search for multiple tags passed as an array of symbols" do
- included = create(:article, tags: "includeme, please, lol")
- excluded_partial_match = create(:article, tags: "excluded, please")
- excluded_no_match = create(:article, tags: "excluded, omg")
-
- articles = described_class.cached_tagged_with(%i[includeme please])
-
- expect(articles).to include(included)
- expect(articles).not_to include(excluded_partial_match)
- expect(articles).not_to include(excluded_no_match)
- expect(articles.to_a).to eq(described_class.tagged_with(%i[includeme please]).to_a)
- end
-
- it "can search for multiple tags passed as an array of Tag objects" do
- included = create(:article, tags: "includeme, please, lol")
- excluded_partial_match = create(:article, tags: "excluded, please")
- excluded_no_match = create(:article, tags: "excluded, omg")
-
- tags = Tag.where(name: %i[includeme please]).to_a
- articles = described_class.cached_tagged_with(tags)
-
- expect(articles).to include(included)
- expect(articles).not_to include(excluded_partial_match)
- expect(articles).not_to include(excluded_no_match)
- expect(articles.to_a).to eq(described_class.tagged_with(%i[includeme please]).to_a)
- end
- end
-
- describe ".cached_tagged_with_any" do
- it "can search for a single tag" do
- included = create(:article, tags: "includeme")
- excluded = create(:article, tags: "lol, nope")
-
- articles = described_class.cached_tagged_with_any("includeme")
-
- expect(articles).to include included
- expect(articles).not_to include excluded
- expect(articles.to_a).to eq described_class.tagged_with("includeme", any: true).to_a
- end
-
- it "can search for a single tag when given a symbol" do
- included = create(:article, tags: "includeme")
- excluded = create(:article, tags: "lol, nope")
-
- articles = described_class.cached_tagged_with_any(:includeme)
-
- expect(articles).to include(included)
- expect(articles).not_to include(excluded)
- expect(articles.to_a).to eq(described_class.tagged_with("includeme", any: true).to_a)
- end
-
- it "can search for a single tag when given a Tag object" do
- included = create(:article, tags: "includeme")
- excluded = create(:article, tags: "lol, nope")
-
- tag = Tag.find_by(name: :includeme)
- articles = described_class.cached_tagged_with_any(tag)
-
- expect(articles).to include(included)
- expect(articles).not_to include(excluded)
- expect(articles.to_a).to eq(described_class.tagged_with("includeme", any: true).to_a)
- end
-
- it "can search among multiple tags" do
- included = [
- create(:article, tags: "omg, wtf"),
- create(:article, tags: "omg, lol"),
- ]
- excluded = create(:article, tags: "nope, excluded")
-
- articles = described_class.cached_tagged_with_any("omg")
- expected = described_class.tagged_with("omg", any: true).to_a
-
- expect(articles).to include(*included)
- expect(articles).not_to include excluded
- expect(articles.to_a).to include(*expected)
- end
-
- it "can search for multiple tags" do
- included = create(:article, tags: "includeme, please, lol")
- included_partial_match = create(:article, tags: "includeme, omg")
- excluded_no_match = create(:article, tags: "excluded, omg")
-
- articles = described_class.cached_tagged_with_any(%w[includeme please])
- expected = described_class.tagged_with(%w[includeme please], any: true).to_a
-
- expect(articles).to include included
- expect(articles).to include included_partial_match
- expect(articles).not_to include excluded_no_match
-
- expect(articles.to_a).to include(*expected)
- end
-
- it "can search for multiple tags when given an array of symbols" do
- included = create(:article, tags: "includeme, please, lol")
- included_partial_match = create(:article, tags: "includeme, omg")
- excluded_no_match = create(:article, tags: "excluded, omg")
-
- articles = described_class.cached_tagged_with_any(%i[includeme please])
- expected = described_class.tagged_with(%i[includeme please], any: true).to_a
-
- expect(articles).to include(included)
- expect(articles).to include(included_partial_match)
- expect(articles).not_to include(excluded_no_match)
-
- expect(articles.to_a).to include(*expected)
- end
-
- it "can search for multiple tags when given an array of Tag objects" do
- included = create(:article, tags: "includeme, please, lol")
- included_partial_match = create(:article, tags: "includeme, omg")
- excluded_no_match = create(:article, tags: "excluded, omg")
-
- tags = Tag.where(name: %i[includeme please]).to_a
- articles = described_class.cached_tagged_with_any(tags)
- expected = described_class.tagged_with(%i[includeme please], any: true).to_a
-
- expect(articles).to include(included)
- expect(articles).to include(included_partial_match)
- expect(articles).not_to include(excluded_no_match)
-
- expect(articles.to_a).to include(*expected)
- end
- end
-
- describe ".not_cached_tagged_with_any" do
- it "can exclude multiple tags when given an array of strings" do
- included = create(:article, tags: "includeme")
- excluded1 = create(:article, tags: "includeme, lol")
- excluded2 = create(:article, tags: "includeme, omg")
-
- articles = described_class
- .cached_tagged_with_any("includeme")
- .not_cached_tagged_with_any(%w[lol omg])
-
- expect(articles).to include included
- expect(articles).not_to include excluded1
- expect(articles).not_to include excluded2
- end
- end
-
context "when callbacks are triggered before save" do
it "assigns path on save" do
expect(article.path).to eq("/#{article.username}/#{article.slug}")
diff --git a/spec/models/display_ad_spec.rb b/spec/models/display_ad_spec.rb
index c2a78982f..fe4ed5df7 100644
--- a/spec/models/display_ad_spec.rb
+++ b/spec/models/display_ad_spec.rb
@@ -4,6 +4,8 @@ RSpec.describe DisplayAd, type: :model do
let(:organization) { create(:organization) }
let(:display_ad) { create(:display_ad, organization_id: organization.id) }
+ it_behaves_like "Taggable"
+
describe "validations" do
describe "builtin validations" do
subject { display_ad }
@@ -13,6 +15,7 @@ RSpec.describe DisplayAd, type: :model do
it { is_expected.to validate_presence_of(:placement_area) }
it { is_expected.to validate_presence_of(:body_markdown) }
+ it { is_expected.to have_many(:tags) }
end
it "allows sidebar_right" do
@@ -82,6 +85,68 @@ RSpec.describe DisplayAd, type: :model do
end
end
+ context "when considering article_tags" do
+ it "will show the display ads that contain tags that match any of the article tags" do
+ display_ad = create(:display_ad, organization_id: organization.id,
+ placement_area: "post_comments",
+ published: true,
+ approved: true,
+ cached_tag_list: "linux, git, go")
+
+ create(:display_ad, organization_id: organization.id,
+ placement_area: "post_comments",
+ published: true,
+ approved: true,
+ cached_tag_list: "career")
+
+ article_tags = %w[linux productivity]
+ expect(described_class.for_display("post_comments", false, article_tags)).to eq(display_ad)
+ end
+
+ it "will show display ads that have no tags set" do
+ display_ad = create(:display_ad, organization_id: organization.id,
+ placement_area: "post_comments",
+ published: true,
+ approved: true,
+ cached_tag_list: "")
+
+ create(:display_ad, organization_id: organization.id,
+ placement_area: "post_comments",
+ published: true,
+ approved: true,
+ cached_tag_list: "career")
+
+ article_tags = %w[productivity java]
+ expect(described_class.for_display("post_comments", false, article_tags)).to eq(display_ad)
+ end
+
+ it "will show no display ads if the available display ads have no tags set or do not contain matching tags" do
+ create(:display_ad, organization_id: organization.id,
+ placement_area: "post_comments",
+ published: true,
+ approved: true,
+ cached_tag_list: "productivity")
+ article_tags = %w[javascript]
+ expect(described_class.for_display("post_comments", false, article_tags)).to be_nil
+ end
+
+ it "will show display ads with no tags set if there are no article tags" do
+ create(:display_ad, organization_id: organization.id,
+ placement_area: "post_comments",
+ published: true,
+ approved: true,
+ cached_tag_list: "productivity")
+
+ display_ad_without_tags = create(:display_ad, organization_id: organization.id,
+ placement_area: "post_comments",
+ published: true,
+ approved: true,
+ cached_tag_list: "")
+
+ expect(described_class.for_display("post_comments", false)).to eq(display_ad_without_tags)
+ end
+ end
+
context "when display_to is set to 'logged_in' or 'logged_out'" do
let!(:display_ad2) do
create(:display_ad, organization_id: organization.id, published: true, approved: true, display_to: "logged_in")
diff --git a/spec/models/shared_examples/taggable_spec.rb b/spec/models/shared_examples/taggable_spec.rb
new file mode 100644
index 000000000..51a2ad984
--- /dev/null
+++ b/spec/models/shared_examples/taggable_spec.rb
@@ -0,0 +1,208 @@
+RSpec.shared_examples "Taggable" do
+ let(:model) { described_class } # the class that includes the concern
+ object = described_class.name.underscore.to_sym
+
+ describe ".cached_tagged_with" do
+ it "can search for a single tag" do
+ included = create(object, tag_list: "includeme")
+ excluded = create(object, tag_list: "lol, nope")
+
+ articles = described_class.cached_tagged_with("includeme")
+
+ expect(articles).to include included
+ expect(articles).not_to include excluded
+ expect(articles.to_a).to eq described_class.tagged_with("includeme").to_a
+ end
+
+ it "can search for a single tag when given a symbol" do
+ included = create(object, tag_list: "includeme")
+ excluded = create(object, tag_list: "lol, nope")
+
+ articles = described_class.cached_tagged_with(:includeme)
+
+ expect(articles).to include(included)
+ expect(articles).not_to include(excluded)
+ expect(articles.to_a).to eq(described_class.tagged_with("includeme").to_a)
+ end
+
+ it "can search for a single tag when given a Tag object" do
+ included = create(object, tag_list: "includeme")
+ excluded = create(object, tag_list: "lol, nope")
+
+ tag = Tag.find_by(name: :includeme)
+
+ articles = described_class.cached_tagged_with(tag)
+
+ expect(articles).to include included
+ expect(articles).not_to include excluded
+ expect(articles.to_a).to eq described_class.tagged_with("includeme").to_a
+ end
+
+ it "can search among multiple tags" do
+ included = [
+ create(object, tag_list: "omg, wtf"),
+ create(object, tag_list: "omg, lol"),
+ ]
+ excluded = create(object, tag_list: "nope, excluded")
+
+ articles = described_class.cached_tagged_with("omg")
+
+ expect(articles).to include(*included)
+ expect(articles).not_to include excluded
+ expect(articles.to_a).to include(*described_class.tagged_with("omg").to_a)
+ end
+
+ it "can search for multiple tags" do
+ included = create(object, tag_list: "includeme, please, lol")
+ excluded_partial_match = create(object, tag_list: "excluded, please")
+ excluded_no_match = create(object, tag_list: "excluded, omg")
+
+ articles = described_class.cached_tagged_with(%w[includeme please])
+
+ expect(articles).to include included
+ expect(articles).not_to include excluded_partial_match
+ expect(articles).not_to include excluded_no_match
+ expect(articles.to_a).to eq described_class.tagged_with(%w[includeme please]).to_a
+ end
+
+ it "can search for multiple tags passed as an array of symbols" do
+ included = create(object, tag_list: "includeme, please, lol")
+ excluded_partial_match = create(object, tag_list: "excluded, please")
+ excluded_no_match = create(object, tag_list: "excluded, omg")
+
+ articles = described_class.cached_tagged_with(%i[includeme please])
+
+ expect(articles).to include(included)
+ expect(articles).not_to include(excluded_partial_match)
+ expect(articles).not_to include(excluded_no_match)
+ expect(articles.to_a).to eq(described_class.tagged_with(%i[includeme please]).to_a)
+ end
+
+ it "can search for multiple tags passed as an array of Tag objects" do
+ included = create(object, tag_list: "includeme, please, lol")
+ excluded_partial_match = create(object, tag_list: "excluded, please")
+ excluded_no_match = create(object, tag_list: "excluded, omg")
+
+ tags = Tag.where(name: %i[includeme please]).to_a
+ articles = described_class.cached_tagged_with(tags)
+
+ expect(articles).to include(included)
+ expect(articles).not_to include(excluded_partial_match)
+ expect(articles).not_to include(excluded_no_match)
+ expect(articles.to_a).to eq(described_class.tagged_with(%i[includeme please]).to_a)
+ end
+ end
+
+ describe ".cached_tagged_with_any" do
+ it "can search for a single tag" do
+ included = create(object, tag_list: "includeme")
+ excluded = create(object, tag_list: "lol, nope")
+
+ articles = described_class.cached_tagged_with_any("includeme")
+
+ expect(articles).to include included
+ expect(articles).not_to include excluded
+ expect(articles.to_a).to eq described_class.tagged_with("includeme", any: true).to_a
+ end
+
+ it "can search for a single tag when given a symbol" do
+ included = create(object, tag_list: "includeme")
+ excluded = create(object, tag_list: "lol, nope")
+
+ articles = described_class.cached_tagged_with_any(:includeme)
+
+ expect(articles).to include(included)
+ expect(articles).not_to include(excluded)
+ expect(articles.to_a).to eq(described_class.tagged_with("includeme", any: true).to_a)
+ end
+
+ it "can search for a single tag when given a Tag object" do
+ included = create(object, tag_list: "includeme")
+ excluded = create(object, tag_list: "lol, nope")
+
+ tag = Tag.find_by(name: :includeme)
+ articles = described_class.cached_tagged_with_any(tag)
+
+ expect(articles).to include(included)
+ expect(articles).not_to include(excluded)
+ expect(articles.to_a).to eq(described_class.tagged_with("includeme", any: true).to_a)
+ end
+
+ it "can search among multiple tags" do
+ included = [
+ create(object, tag_list: "omg, wtf"),
+ create(object, tag_list: "omg, lol"),
+ ]
+ excluded = create(object, tag_list: "nope, excluded")
+
+ articles = described_class.cached_tagged_with_any("omg")
+ expected = described_class.tagged_with("omg", any: true).to_a
+
+ expect(articles).to include(*included)
+ expect(articles).not_to include excluded
+ expect(articles.to_a).to include(*expected)
+ end
+
+ it "can search for multiple tags" do
+ included = create(object, tag_list: "includeme, please, lol")
+ included_partial_match = create(object, tag_list: "includeme, omg")
+ excluded_no_match = create(object, tag_list: "excluded, omg")
+
+ articles = described_class.cached_tagged_with_any(%w[includeme please])
+ expected = described_class.tagged_with(%w[includeme please], any: true).to_a
+
+ expect(articles).to include included
+ expect(articles).to include included_partial_match
+ expect(articles).not_to include excluded_no_match
+
+ expect(articles.to_a).to include(*expected)
+ end
+
+ it "can search for multiple tags when given an array of symbols" do
+ included = create(object, tag_list: "includeme, please, lol")
+ included_partial_match = create(object, tag_list: "includeme, omg")
+ excluded_no_match = create(object, tag_list: "excluded, omg")
+
+ articles = described_class.cached_tagged_with_any(%i[includeme please])
+ expected = described_class.tagged_with(%i[includeme please], any: true).to_a
+
+ expect(articles).to include(included)
+ expect(articles).to include(included_partial_match)
+ expect(articles).not_to include(excluded_no_match)
+
+ expect(articles.to_a).to include(*expected)
+ end
+
+ it "can search for multiple tags when given an array of Tag objects" do
+ included = create(object, tag_list: "includeme, please, lol")
+ included_partial_match = create(object, tag_list: "includeme, omg")
+ excluded_no_match = create(object, tag_list: "excluded, omg")
+
+ tags = Tag.where(name: %i[includeme please]).to_a
+ articles = described_class.cached_tagged_with_any(tags)
+ expected = described_class.tagged_with(%i[includeme please], any: true).to_a
+
+ expect(articles).to include(included)
+ expect(articles).to include(included_partial_match)
+ expect(articles).not_to include(excluded_no_match)
+
+ expect(articles.to_a).to include(*expected)
+ end
+ end
+
+ describe ".not_cached_tagged_with_any" do
+ it "can exclude multiple tags when given an array of strings" do
+ included = create(object, tag_list: "includeme")
+ excluded1 = create(object, tag_list: "includeme, lol")
+ excluded2 = create(object, tag_list: "includeme, omg")
+
+ articles = described_class
+ .cached_tagged_with_any("includeme")
+ .not_cached_tagged_with_any(%w[lol omg])
+
+ expect(articles).to include included
+ expect(articles).not_to include excluded1
+ expect(articles).not_to include excluded2
+ end
+ end
+end