diff --git a/app/controllers/artist_urls_controller.rb b/app/controllers/artist_urls_controller.rb index 1c75ece3b..cd3c97e54 100644 --- a/app/controllers/artist_urls_controller.rb +++ b/app/controllers/artist_urls_controller.rb @@ -1,6 +1,14 @@ class ArtistUrlsController < ApplicationController - respond_to :json - before_action :member_only + respond_to :json, :xml, :html + before_action :member_only, except: [:index] + + def index + @artist_urls = ArtistUrl.includes(:artist).search(search_params).paginate(params[:page], :limit => params[:limit], :search_count => params[:search]) + respond_with(@artist_urls) do |format| + format.json { render json: @artist_urls.to_json(include: "artist",) } + format.xml { render xml: @artist_urls.to_xml(include: "artist", root: "artist-urls") } + end + end def update @artist_url = ArtistUrl.find(params[:id]) diff --git a/app/models/application_record.rb b/app/models/application_record.rb index a28754a34..b2f627553 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -5,6 +5,36 @@ class ApplicationRecord < ActiveRecord::Base concerning :SearchMethods do class_methods do + def qualified_column_for(attr) + "#{table_name}.#{column_for_attribute(attr).name}" + end + + def where_like(attr, value) + where("#{qualified_column_for(attr)} LIKE ? ESCAPE E'\\\\'", value.to_escaped_for_sql_like) + end + + def where_not_like(attr, value) + where.not("#{qualified_column_for(attr)} LIKE ? ESCAPE E'\\\\'", value.to_escaped_for_sql_like) + end + + def where_ilike(attr, value) + where("lower(#{qualified_column_for(attr)}) LIKE ? ESCAPE E'\\\\'", value.mb_chars.downcase.to_escaped_for_sql_like) + end + + def where_not_ilike(attr, value) + where.not("lower(#{qualified_column_for(attr)}) LIKE ? ESCAPE E'\\\\'", value.mb_chars.downcase.to_escaped_for_sql_like) + end + + # https://www.postgresql.org/docs/current/static/functions-matching.html#FUNCTIONS-POSIX-REGEXP + # "(?e)" means force use of ERE syntax; see sections 9.7.3.1 and 9.7.3.4. + def where_regex(attr, value) + where("#{qualified_column_for(attr)} ~ ?", "(?e)" + value) + end + + def where_not_regex(attr, value) + where.not("#{qualified_column_for(attr)} ~ ?", "(?e)" + value) + end + def attribute_matches(attribute, value, **options) return all if value.nil? @@ -55,6 +85,30 @@ class ApplicationRecord < ActiveRecord::Base end end + def search_text_attribute(attr, params, **options) + if params[attr].present? + where(attr => params[attr]) + elsif params[:"#{attr}_eq"].present? + where(attr => params[:"#{attr}_eq"]) + elsif params[:"#{attr}_not_eq"].present? + where.not(attr => params[:"#{attr}_not_eq"]) + elsif params[:"#{attr}_like"].present? + where_like(attr, params[:"#{attr}_like"]) + elsif params[:"#{attr}_ilike"].present? + where_ilike(attr, params[:"#{attr}_ilike"]) + elsif params[:"#{attr}_not_like"].present? + where_not_like(attr, params[:"#{attr}_not_like"]) + elsif params[:"#{attr}_not_ilike"].present? + where_not_ilike(attr, params[:"#{attr}_not_ilike"]) + elsif params[:"#{attr}_regex"].present? + where_regex(attr, params[:"#{attr}_regex"]) + elsif params[:"#{attr}_not_regex"].present? + where_not_regex(attr, params[:"#{attr}_not_regex"]) + else + all + end + end + def apply_default_order(params) if params[:order] == "custom" parse_ids = Tag.parse_helper(params[:id]) diff --git a/app/models/artist_url.rb b/app/models/artist_url.rb index 8612ed3fb..14953b796 100644 --- a/app/models/artist_url.rb +++ b/app/models/artist_url.rb @@ -6,6 +6,9 @@ class ArtistUrl < ApplicationRecord validate :validate_url_format belongs_to :artist, :touch => true + scope :url_matches, ->(url) { url_attribute_matches(:url, url) } + scope :normalized_url_matches, ->(url) { url_attribute_matches(:normalized_url, url) } + def self.strip_prefixes(url) url.sub(/^[-]+/, "") end @@ -45,6 +48,46 @@ class ArtistUrl < ApplicationRecord end end + def self.search(params = {}) + q = super + + q = q.attribute_matches(:artist_id, params[:artist_id]) + q = q.attribute_matches(:is_active, params[:is_active]) + q = q.search_text_attribute(:url, params) + q = q.search_text_attribute(:normalized_url, params) + + q = q.artist_matches(params[:artist]) + q = q.url_matches(params[:url_matches]) + q = q.normalized_url_matches(params[:normalized_url_matches]) + + case params[:order] + when /\A(id|artist_id|url|normalized_url|is_active|created_at|updated_at)(?:_(asc|desc))?\z/i + dir = $2 || :desc + q = q.order($1 => dir).order(id: :desc) + else + q = q.apply_default_order(params) + end + + q + end + + def self.artist_matches(params = {}) + return all if params.blank? + where(artist_id: Artist.search(params).reorder(nil)) + end + + def self.url_attribute_matches(attr, url) + if url.blank? + all + elsif url =~ %r!\A/(.*)/\z! + where_regex(attr, $1) + elsif url.include?("*") + where_ilike(attr, url) + else + where(attr => normalize(url)) + end + end + def parse_prefix case url when /^-/ diff --git a/app/views/artist_urls/index.html.erb b/app/views/artist_urls/index.html.erb new file mode 100644 index 000000000..0c86c0a8b --- /dev/null +++ b/app/views/artist_urls/index.html.erb @@ -0,0 +1,49 @@ +
+
+ <%= simple_form_for(:search, url: artist_urls_path, method: :get, defaults: { required: false }, html: { class: "inline-form" }) do |f| %> + <%= f.simple_fields_for :artist do |fa| %> + <%= fa.input :name, label: "Artist Name", input_html: { value: params.dig(:search, :artist, :name), "data-autocomplete": "artist" } %> + <% end %> + <%= f.input :url_matches, label: "URL", input_html: { value: params[:search][:url_matches] } %> + <%= f.input :normalized_url_matches, label: "Normalized URL", input_html: { value: params[:search][:normalized_url_matches] } %> + <%= f.input :is_active, label: "Active?", collection: [["Yes", true], ["No", false]], include_blank: true, selected: params[:search][:is_active] %> + <%= f.input :order, collection: [["ID", "id"], ["Created", "created_at"], ["Updated", "updated_at"]], selected: params[:search][:order] %> + <%= f.submit "Search" %> + <% end %> + + + + + + + + + + + + + + + <% @artist_urls.each do |artist_url| %> + + <%= tag.td artist_url.id %> + <%= tag.td link_to(artist_url.artist.name, artist_url.artist) %> + <%= tag.td external_link_to(artist_url.url.to_s) %> + <%= tag.td external_link_to(artist_url.normalized_url) %> + <%= tag.td artist_url.is_active.to_s %> + <%= tag.td artist_url.created_at %> + <%= tag.td artist_url.updated_at %> + + <% end %> + +
IDArtist NameURLNormalized URLActive?CreatedUpdated
+ + <%= numbered_paginator(@artist_urls) %> +
+
+ +<%= render "artists/secondary_links" %> + +<% content_for(:page_title) do %> + Artist URLs - <%= Danbooru.config.app_name %> +<% end %> diff --git a/app/views/artists/_secondary_links.html.erb b/app/views/artists/_secondary_links.html.erb index a814d6d02..1d68cdcec 100644 --- a/app/views/artists/_secondary_links.html.erb +++ b/app/views/artists/_secondary_links.html.erb @@ -5,6 +5,7 @@ <%= subnav_link_to "Banned", banned_artists_path %> <%= subnav_link_to "New", new_artist_path %> <%= subnav_link_to "Recent changes", artist_versions_path %> + <%= subnav_link_to "URLs", artist_urls_path %> <% if @artist && !@artist.new_record? %>
  • |
  • <%= subnav_link_to "Posts (#{Post.fast_count(@artist.name)})", posts_path(:tags => @artist.name) %> diff --git a/config/routes.rb b/config/routes.rb index d4a354b7a..a56ed5057 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,7 +81,7 @@ Rails.application.routes.draw do get :banned end end - resources :artist_urls, only: [:update] + resources :artist_urls, only: [:index, :update] resources :artist_versions, :only => [:index] do collection do get :search diff --git a/db/migrate/20180916002448_add_trigram_index_to_artist_urls.rb b/db/migrate/20180916002448_add_trigram_index_to_artist_urls.rb new file mode 100644 index 000000000..dd511a7b8 --- /dev/null +++ b/db/migrate/20180916002448_add_trigram_index_to_artist_urls.rb @@ -0,0 +1,12 @@ +class AddTrigramIndexToArtistUrls < ActiveRecord::Migration[5.2] + def change + change_table :artist_urls do |t| + t.remove_index column: :url, name: :index_artist_urls_on_url + t.remove_index column: :url, name: :index_artist_urls_on_url_pattern, opclass: :text_pattern_ops + t.remove_index column: :normalized_url, name: :index_artist_urls_on_normalized_url + + t.index :url, name: :index_artist_urls_on_url_trgm, using: :gin, opclass: :gin_trgm_ops + t.index :normalized_url, name: :index_artist_urls_on_normalized_url_trgm, using: :gin, opclass: :gin_trgm_ops + end + end +end diff --git a/db/structure.sql b/db/structure.sql index 243ae4d47..650beb267 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -4944,13 +4944,6 @@ CREATE INDEX index_artist_commentary_versions_on_updater_ip_addr ON public.artis CREATE INDEX index_artist_urls_on_artist_id ON public.artist_urls USING btree (artist_id); --- --- Name: index_artist_urls_on_normalized_url; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_artist_urls_on_normalized_url ON public.artist_urls USING btree (normalized_url); - - -- -- Name: index_artist_urls_on_normalized_url_pattern; Type: INDEX; Schema: public; Owner: - -- @@ -4959,17 +4952,17 @@ CREATE INDEX index_artist_urls_on_normalized_url_pattern ON public.artist_urls U -- --- Name: index_artist_urls_on_url; Type: INDEX; Schema: public; Owner: - +-- Name: index_artist_urls_on_normalized_url_trgm; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX index_artist_urls_on_url ON public.artist_urls USING btree (url); +CREATE INDEX index_artist_urls_on_normalized_url_trgm ON public.artist_urls USING gin (normalized_url public.gin_trgm_ops); -- --- Name: index_artist_urls_on_url_pattern; Type: INDEX; Schema: public; Owner: - +-- Name: index_artist_urls_on_url_trgm; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX index_artist_urls_on_url_pattern ON public.artist_urls USING btree (url text_pattern_ops); +CREATE INDEX index_artist_urls_on_url_trgm ON public.artist_urls USING gin (url public.gin_trgm_ops); -- @@ -7532,6 +7525,8 @@ INSERT INTO "schema_migrations" (version) VALUES ('20180518175154'), ('20180804203201'), ('20180816230604'), -('20180912185624'); +('20180912185624'), +('20180913184128'), +('20180916002448'); diff --git a/test/functional/artist_urls_controller_test.rb b/test/functional/artist_urls_controller_test.rb new file mode 100644 index 000000000..124f13a73 --- /dev/null +++ b/test/functional/artist_urls_controller_test.rb @@ -0,0 +1,25 @@ +require 'test_helper' + +class ArtistUrlsControllerTest < ActionDispatch::IntegrationTest + context "The artist urls controller" do + context "index page" do + should "render" do + get artist_urls_path + assert_response :success + end + + should "render for a complex search" do + @artist = FactoryBot.create(:artist, name: "bkub", url_string: "-http://bkub.com") + + get artist_urls_path(search: { + artist: { name: "bkub", }, + url_matches: "*bkub*", + is_active: "false", + order: "created_at" + }) + + assert_response :success + end + end + end +end diff --git a/test/unit/artist_url_test.rb b/test/unit/artist_url_test.rb index f38e54dcd..fbbc588b0 100644 --- a/test/unit/artist_url_test.rb +++ b/test/unit/artist_url_test.rb @@ -1,6 +1,10 @@ require 'test_helper' class ArtistUrlTest < ActiveSupport::TestCase + def assert_search_equals(results, conditions) + assert_equal(results.map(&:id), subject.search(conditions).map(&:id)) + end + context "An artist url" do setup do CurrentUser.user = FactoryBot.create(:user) @@ -165,5 +169,36 @@ class ArtistUrlTest < ActiveSupport::TestCase url = FactoryBot.create(:artist_url, url: "https://nijie.info/members.php?id=161703") assert_equal("http://nijie.info/members.php?id=161703/", url.normalized_url) end + + context "#search method" do + subject { ArtistUrl } + + should "work" do + @bkub = FactoryBot.create(:artist, name: "bkub", is_active: true, url_string: "https://bkub.com") + @masao = FactoryBot.create(:artist, name: "masao", is_active: false, url_string: "-https://masao.com") + @bkub_url = @bkub.urls.first + @masao_url = @masao.urls.first + + assert_search_equals([@bkub_url], is_active: true) + assert_search_equals([@bkub_url], artist: { name: "bkub" }) + + assert_search_equals([@bkub_url], url_matches: "*bkub*") + assert_search_equals([@bkub_url], url_matches: "/^https?://bkub\.com$/") + + assert_search_equals([@bkub_url], normalized_url_matches: "*bkub*") + assert_search_equals([@bkub_url], normalized_url_matches: "/^https?://bkub\.com/$/") + assert_search_equals([@bkub_url], normalized_url_matches: "https://bkub.com") + + assert_search_equals([@bkub_url], url: "https://bkub.com") + assert_search_equals([@bkub_url], url_eq: "https://bkub.com") + assert_search_equals([@bkub_url], url_not_eq: "https://masao.com") + assert_search_equals([@bkub_url], url_like: "*bkub*") + assert_search_equals([@bkub_url], url_ilike: "*BKUB*") + assert_search_equals([@bkub_url], url_not_like: "*masao*") + assert_search_equals([@bkub_url], url_not_ilike: "*MASAO*") + assert_search_equals([@bkub_url], url_regex: "bkub") + assert_search_equals([@bkub_url], url_not_regex: "masao") + end + end end end