From 9759701071c1988c94786c853d0c7efac927fefb Mon Sep 17 00:00:00 2001 From: evazion Date: Sat, 9 Jan 2021 20:19:49 -0600 Subject: [PATCH] search: add way to search array attributes by regex. Add a `where_any_in_array_matches_regex` method and expose it to the API: * https://danbooru.donmai.us/artists?search[any_other_name_matches_regex]=^blah * https://danbooru.donmai.us/wiki_pages?search[any_other_name_matches_regex]=^blah * https://danbooru.donmai.us/saved_searches?search[any_label_matches_regex]=^blah In SQL, this does `WHERE '^blah' ~<< ANY(other_names)`, where `~<<` is a custom operator based on the `~` regex match operator, but with the arguments reversed. This allows it to be used with the ANY(array) operator. See also: * https://stackoverflow.com/a/22101172 * https://www.postgresql.org/docs/current/sql-createfunction.html * https://www.postgresql.org/docs/current/sql-createoperator.html * https://www.postgresql.org/docs/current/functions-comparisons.html --- app/logical/concerns/searchable.rb | 12 ++++++++-- ...210110015410_add_reverse_regex_operator.rb | 11 +++++++++ db/structure.sql | 23 ++++++++++++++++++- test/unit/concerns/searchable.rb | 3 +++ 4 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20210110015410_add_reverse_regex_operator.rb diff --git a/app/logical/concerns/searchable.rb b/app/logical/concerns/searchable.rb index db7f7788b..ebc8548ed 100644 --- a/app/logical/concerns/searchable.rb +++ b/app/logical/concerns/searchable.rb @@ -92,6 +92,11 @@ module Searchable where("lower(#{qualified_column_for(attr)}::text)::text[] @> ARRAY[?]", values.map(&:downcase)) end + # `~<<` is a custom Postgres operator. It's the `~` regex operator with reversed arguments. + def where_any_in_array_matches_regex(attr, regex, flags: "e") + where("? ~<< ANY(#{qualified_column_for(attr)})", "(?#{flags})#{regex}") + end + def where_text_includes_lower(attr, values) where("lower(#{qualified_column_for(attr)}) IN (?)", values.map(&:downcase)) end @@ -340,6 +345,7 @@ module Searchable def search_array_attribute(name, type, params) relation = all + singular_name = name.to_s.singularize if params[:"#{name}_include_any"] items = params[:"#{name}_include_any"].to_s.scan(/[^[:space:]]+/) @@ -369,10 +375,12 @@ module Searchable relation = relation.where_array_includes_any_lower(name, params[:"#{name}_include_any_lower_array"]) elsif params[:"#{name}_include_all_lower_array"] relation = relation.where_array_includes_all_lower(name, params[:"#{name}_include_all_lower_array"]) + elsif params[:"any_#{singular_name}_matches_regex"] + relation = relation.where_any_in_array_matches_regex(name, params[:"any_#{singular_name}_matches_regex"]) end - if params[:"#{name.to_s.singularize}_count"] - relation = relation.where_array_count(name, params[:"#{name.to_s.singularize}_count"]) + if params[:"#{singular_name}_count"] + relation = relation.where_array_count(name, params[:"#{singular_name}_count"]) end relation diff --git a/db/migrate/20210110015410_add_reverse_regex_operator.rb b/db/migrate/20210110015410_add_reverse_regex_operator.rb new file mode 100644 index 000000000..ad2ecd5c9 --- /dev/null +++ b/db/migrate/20210110015410_add_reverse_regex_operator.rb @@ -0,0 +1,11 @@ +class AddReverseRegexOperator < ActiveRecord::Migration[6.1] + def up + execute "CREATE FUNCTION reverse_textregexeq (text, text) RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ SELECT textregexeq($2, $1); $$" + execute "CREATE OPERATOR ~<< (FUNCTION = reverse_textregexeq, leftarg = text, rightarg = text)" + end + + def down + execute "DROP OPERATOR ~<< (text, text)" + execute "DROP FUNCTION reverse_textregexeq (text, text)" + end +end diff --git a/db/structure.sql b/db/structure.sql index 9508f40a1..e6f0daaa5 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -352,6 +352,15 @@ CREATE FUNCTION public.favorites_insert_trigger() RETURNS trigger $$; +-- +-- Name: reverse_textregexeq(text, text); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.reverse_textregexeq(text, text) RETURNS boolean + LANGUAGE sql IMMUTABLE PARALLEL SAFE + AS $_$ SELECT textregexeq($2, $1); $_$; + + -- -- Name: testprs_end(internal); Type: FUNCTION; Schema: public; Owner: - -- @@ -388,6 +397,17 @@ CREATE FUNCTION public.testprs_start(internal, integer) RETURNS internal AS '$libdir/test_parser', 'testprs_start'; +-- +-- Name: ~<<; Type: OPERATOR; Schema: public; Owner: - +-- + +CREATE OPERATOR public.~<< ( + FUNCTION = public.reverse_textregexeq, + LEFTARG = text, + RIGHTARG = text +); + + -- -- Name: testparser; Type: TEXT SEARCH PARSER; Schema: public; Owner: - -- @@ -7849,6 +7869,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20210106212805'), ('20210108030722'), ('20210108030723'), -('20210108030724'); +('20210108030724'), +('20210110015410'); diff --git a/test/unit/concerns/searchable.rb b/test/unit/concerns/searchable.rb index 8563680b4..ddb32e69a 100644 --- a/test/unit/concerns/searchable.rb +++ b/test/unit/concerns/searchable.rb @@ -127,6 +127,9 @@ class SearchableTest < ActiveSupport::TestCase assert_search_equals(@wp, other_names_include_any_lower_array: ["A1", "BLAH"]) assert_search_equals(@wp, other_names_include_all_lower_array: ["A1", "B2"]) + assert_search_equals(@wp, any_other_name_matches_regex: "^a") + assert_search_equals(@wp, any_other_name_matches_regex: "[a-z][0-9]") + assert_search_equals(@wp, other_name_count: 2) end end