From ca48853d46c4bb40ea0cb039b0507447facfdc32 Mon Sep 17 00:00:00 2001 From: James Smith Date: Tue, 29 Jul 2025 11:53:01 +0100 Subject: [PATCH] refactor filterable controller code into a filter service object --- app/services/search/filter_service.rb | 133 ++++++++++++++++++++ spec/services/search/filter_service_spec.rb | 104 +++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 app/services/search/filter_service.rb create mode 100644 spec/services/search/filter_service_spec.rb diff --git a/app/services/search/filter_service.rb b/app/services/search/filter_service.rb new file mode 100644 index 000000000..9b70b7143 --- /dev/null +++ b/app/services/search/filter_service.rb @@ -0,0 +1,133 @@ +class Search::FilterService + attr_reader :collection + attr_reader :creator + + # Get list filters from URL + def initialize(params) + @filters = params.permit( + :library, + :collection, + :q, + :creator, + :link, + :missingtag, + tag: [] + ).compact || {} + @collection = Collection.find_param(parameter(:collection)) if parameter(:collection).present? + @creator = Creator.find_param(parameter(:creator)) if parameter(:creator).present? + end + + def any? + !@filters.empty? + end + + def filtering_by?(key) + parameter(key).present? + end + + def parameter(key) + @filters[key] + end + + def to_params(except: nil) + ActionController::Parameters.new(@filters.except(except)) + end + + def tags + end + + def models(scope) + scope = scope.all + scope = filter_by_library(scope) + scope = filter_by_missing_tag(scope) + scope = filter_by_tag(scope) + scope = filter_by_collection(scope) + scope = filter_by_creator(scope) + scope = filter_by_url(scope) + filter_by_search(scope) + end + + private + + # Filter by library + def filter_by_library(scope) + filtering_by?(:library) ? scope.where(library: Library.find_param(parameter(:library))) : scope + end + + # Filter by collection + def filter_by_collection(scope) + case parameter(:collection) + when nil + scope # No collection, move along + when "" + scope.where(collection_id: nil) + else + scope.where(collection: Collection.tree_down(@collection.id)) + end + end + + # Filter by creator + def filter_by_creator(scope) + case parameter(:creator) + when nil + scope # No creator specified, nothing to do + when "" + scope.where(creator_id: nil) + else + scope.where(creator: creator) + end + end + + # Filter by tag + def filter_by_tag(scope) + case parameter(:tag) + when nil + scope # No tags, move along + when [""] + scope.where("(select count(*) from taggings where taggings.taggable_id=models.id and taggings.context='tags')<1") + else + # Build query directly rather than using tagged_with, which parses the tag list again using default separators + ::ActsAsTaggableOn::Taggable::TaggedWithQuery.build(scope, ActsAsTaggableOn::Tag, ActsAsTaggableOn::Tagging, parameter(:tag), {}) + end + end + + # Filter by url + def filter_by_url(scope) + case parameter(:link) + when nil + scope # no filter + when "" + scope.where("(select count(*) from links where linkable_id=models.id and linkable_type='Model')<1") + else + scope.where("(select count(*) from links where linkable_id=models.id and linkable_type='Model' and url like ?)>0", "%#{parameter(:link)}%") + end + end + + # Filter by search query + def filter_by_search(scope) + if parameter(:q) + Search::ModelSearchService.new(scope).search(parameter(:q)) + else + scope + end + end + + def filter_by_missing_tag(scope) + # Missing tags (If specific tag is not specified, require library to be set) + if filtering_by?(:missingtag) || (filtering_by?(:missingtag) && parameter(:library)) + tag_regex_build = [] + regexes = ((parameter(:missingtag) != "") ? [parameter(:missingtag)] : Library.find_param(parameter(:library)).tag_regex) + # Regexp match syntax - postgres is different from MySQL and SQLite + regact = (ApplicationRecord.connection.adapter_name == "PostgreSQL") ? "~" : "REGEXP" + regexes.each do |reg| + qreg = ActiveRecord::Base.connection.quote(reg) + tag_regex_build.push "(select count(*) from tags join taggings on tags.id=taggings.tag_id where tags.name #{regact} #{qreg} and taggings.taggable_id=models.id and taggings.taggable_type='Model')<1" + end + qreg = ActiveRecord::Base.connection.quote(parameter(:missingtag)) + tag_regex_build.push "(select count(*) from tags join taggings on tags.id=taggings.tag_id where tags.name #{regact} #{qreg} and taggings.taggable_id=models.id and taggings.taggable_type='Model')<1" + scope.where("(" + tag_regex_build.join(" OR ") + ")") + else + scope + end + end +end diff --git a/spec/services/search/filter_service_spec.rb b/spec/services/search/filter_service_spec.rb new file mode 100644 index 000000000..008edd343 --- /dev/null +++ b/spec/services/search/filter_service_spec.rb @@ -0,0 +1,104 @@ +require "rails_helper" + +RSpec.describe Search::FilterService do + context "with a range of models" do + let(:creator) { create(:creator, name: "Dr Seuss") } + let(:collection) { create(:collection, name: "Chiroptera") } + + before do + create(:model, name: "cat in the hat", tag_list: ["dog", "log", "frog", "cat"], creator: creator, notes: "lorem ipsum", caption: nil) + create(:model, name: "hat on the cat", tag_list: [], creator: creator, notes: nil, caption: "dolor sit amet") + create(:model, name: "bat on a mat", tag_list: ["log"], collection: collection, notes: nil, caption: nil, links_attributes: [{url: "https://thingiverse.com/thing:1234"}]) + create(:model, name: "bat on a hat", tag_list: ["frog"], collection: collection, notes: nil, caption: nil) + end + + it "returns all models with no filters" do + filter = described_class.new(ActionController::Parameters.new) + expect(filter.models(Model).count).to eq 4 + end + + it "filters on a simple search query" do + service = described_class.new(ActionController::Parameters.new(q: "cat")) + expect(service.models(Model.all).pluck(:name)).to eq [ + "cat in the hat", + "hat on the cat" + ] + end + + it "filters by collection" do + service = described_class.new(ActionController::Parameters.new(collection: collection.to_param)) + expect(service.models(Model.all).pluck(:name)).to eq [ + "bat on a mat", + "bat on a hat" + ] + end + + it "filters by missing collection" do + service = described_class.new(ActionController::Parameters.new(collection: "")) + expect(service.models(Model.all).pluck(:name)).to eq [ + "cat in the hat", + "hat on the cat" + ] + end + + it "filters by creator" do + service = described_class.new(ActionController::Parameters.new(creator: creator.to_param)) + expect(service.models(Model.all).pluck(:name)).to eq [ + "cat in the hat", + "hat on the cat" + ] + end + + it "filters by missing creator" do + service = described_class.new(ActionController::Parameters.new(creator: "")) + expect(service.models(Model.all).pluck(:name)).to eq [ + "bat on a mat", + "bat on a hat" + ] + end + + it "filters by single tag" do + service = described_class.new(ActionController::Parameters.new(tag: ["log"])) + expect(service.models(Model.all).pluck(:name)).to eq [ + "cat in the hat", + "bat on a mat" + ] + end + + it "filters by no tags" do + service = described_class.new(ActionController::Parameters.new(tag: [""])) + expect(service.models(Model.all).pluck(:name)).to eq [ + "hat on the cat" + ] + end + + it "filters by multiple tags" do + service = described_class.new(ActionController::Parameters.new(tag: ["log", "frog"])) + expect(service.models(Model.all).pluck(:name)).to eq [ + "cat in the hat" + ] + end + + it "filters by missing tag" do + service = described_class.new(ActionController::Parameters.new(missingtag: "log")) + expect(service.models(Model.all).pluck(:name)).to eq [ + "hat on the cat", + "bat on a hat" + ] + end + + it "filters by library" do + service = described_class.new(ActionController::Parameters.new(library: Library.last.to_param)) + expect(service.models(Model.all).pluck(:name)).to eq [ + "bat on a hat" + ] + end + + it "filters by link" do + service = described_class.new(ActionController::Parameters.new(link: "thingiverse.com")) + expect(service.models(Model.all).pluck(:name)).to eq [ + "bat on a mat" + ] + end + end +end