openstax_utilities 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/routines/openstax/utilities/limit_and_paginate_relation.rb +85 -0
- data/app/routines/openstax/utilities/order_relation.rb +105 -0
- data/app/routines/openstax/utilities/search_and_organize_relation.rb +130 -0
- data/app/routines/openstax/utilities/search_relation.rb +89 -0
- data/lib/openstax/utilities/version.rb +1 -1
- data/spec/dummy/{app/routines → config/initializers}/search_users.rb +9 -4
- data/spec/routines/openstax/utilities/limit_and_paginate_relation_spec.rb +72 -0
- data/spec/routines/openstax/utilities/order_relation_spec.rb +57 -0
- data/spec/routines/openstax/utilities/{abstract_keyword_search_routine_spec.rb → search_and_organize_relation_spec.rb} +50 -43
- data/spec/routines/openstax/utilities/search_relation_spec.rb +81 -0
- metadata +16 -10
- data/app/handlers/openstax/utilities/keyword_search_handler.rb +0 -95
- data/app/routines/openstax/utilities/abstract_keyword_search_routine.rb +0 -158
- data/spec/handlers/openstax/utilities/keyword_search_handler_spec.rb +0 -126
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7336e5f9c9952756f9a981da7b79d5dece466022
|
4
|
+
data.tar.gz: e6a8621a11547eae03d63fc0d94e05053cc86664
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7b17cb61f65fe32b021ce90b736ff163fedc34ffcb3173f9ba0af024d165a4b53ea586547df15cda7b3d787bcf2afc3cf5a2c39a69ce1bc546ddce1056c6a6cf
|
7
|
+
data.tar.gz: a5a03f359924bc9de55fd70e3b433fd3ef84dbed7c94d1c7ff40e802b1c5cc65d452a6bf09d77198872d94f82f3e3da6ca65a773f1becd7d6b003a122605c16c
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# Database-agnostic search result limiting and pagination routine
|
2
|
+
#
|
3
|
+
# Counts the number of items in a relation and empties it
|
4
|
+
# if the number exceeds the specified absolute maximum
|
5
|
+
# Otherwise, applies the specified pagination
|
6
|
+
#
|
7
|
+
# Callers of this routine provide the relation argument and
|
8
|
+
# may provide the max_items, per_page and page arguments
|
9
|
+
#
|
10
|
+
# Required arguments:
|
11
|
+
#
|
12
|
+
# Developer-supplied:
|
13
|
+
#
|
14
|
+
# relation - the ActiveRecord::Relation to be limited or paginated
|
15
|
+
#
|
16
|
+
# Optional arguments:
|
17
|
+
#
|
18
|
+
# Developer-supplied:
|
19
|
+
#
|
20
|
+
# max_items - the maximum allowed number of search results
|
21
|
+
# default: nil (disabled)
|
22
|
+
#
|
23
|
+
# User or developer-supplied:
|
24
|
+
#
|
25
|
+
# per_page - the maximum number of search results per page
|
26
|
+
# default: nil (disabled)
|
27
|
+
# page - the number of the page to return
|
28
|
+
# default: 1
|
29
|
+
#
|
30
|
+
# This routine's outputs contain:
|
31
|
+
#
|
32
|
+
# outputs[:total_count] - the total number of items in the relation
|
33
|
+
# outputs[:items] - the original relation after it has
|
34
|
+
# potentially been emptied or paginated
|
35
|
+
|
36
|
+
require 'lev'
|
37
|
+
|
38
|
+
module OpenStax
|
39
|
+
module Utilities
|
40
|
+
class LimitAndPaginateRelation
|
41
|
+
|
42
|
+
lev_routine transaction: :no_transaction
|
43
|
+
|
44
|
+
protected
|
45
|
+
|
46
|
+
def exec(*args)
|
47
|
+
|
48
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
49
|
+
relation = options[:relation] || args[0]
|
50
|
+
max_items = options[:max_items] || nil
|
51
|
+
per_page = Integer(options[:per_page]) rescue nil
|
52
|
+
page = Integer(options[:page]) rescue 1
|
53
|
+
|
54
|
+
raise ArgumentError, 'You must specify a :relation option' \
|
55
|
+
if relation.nil?
|
56
|
+
|
57
|
+
fatal_error(offending_inputs: :per_page,
|
58
|
+
message: 'Invalid page size',
|
59
|
+
code: :invalid_per_page) if !per_page.nil? && per_page < 1
|
60
|
+
fatal_error(offending_inputs: :page,
|
61
|
+
message: 'Invalid page number',
|
62
|
+
code: :invalid_page) if page < 1
|
63
|
+
|
64
|
+
outputs[:total_count] = relation.count
|
65
|
+
|
66
|
+
if !max_items.nil? && outputs[:total_count] > max_items
|
67
|
+
# Limiting
|
68
|
+
relation = relation.none
|
69
|
+
nonfatal_error(code: :too_many_items,
|
70
|
+
message: "The number of matches exceeded the allowed limit of #{
|
71
|
+
max_items} matches. Please refine your query and try again.")
|
72
|
+
elsif per_page.nil?
|
73
|
+
relation = relation.none if page > 1
|
74
|
+
else
|
75
|
+
# Pagination
|
76
|
+
relation = relation.limit(per_page).offset(per_page*(page-1))
|
77
|
+
end
|
78
|
+
|
79
|
+
outputs[:items] = relation
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
# Database-agnostic search result ordering routine
|
2
|
+
#
|
3
|
+
# Performs ordering of search results
|
4
|
+
#
|
5
|
+
# Callers of this routine provide the relation,
|
6
|
+
# sortable_fields and order_by arguments
|
7
|
+
#
|
8
|
+
# Required arguments:
|
9
|
+
#
|
10
|
+
# Developer-supplied:
|
11
|
+
#
|
12
|
+
# relation - the ActiveRecord::Relation to be ordered
|
13
|
+
#
|
14
|
+
# sortable_fields - list of fields that can appear in the order_by argument
|
15
|
+
# can be a Hash that maps field names to database columns
|
16
|
+
# or an Array of Strings
|
17
|
+
# invalid fields in order_by will be replaced with
|
18
|
+
# the first field listed here, in :asc order
|
19
|
+
#
|
20
|
+
# Optional arguments:
|
21
|
+
#
|
22
|
+
# User or developer-supplied:
|
23
|
+
#
|
24
|
+
# order_by - list of fields to order by, with optional sort directions
|
25
|
+
# can be (an Array of) Hashes, or Strings
|
26
|
+
# default: {sortable_fields.values.first => :asc}
|
27
|
+
#
|
28
|
+
# This routine's outputs contain:
|
29
|
+
#
|
30
|
+
# outputs[:items] - a relation containing the ordered records
|
31
|
+
|
32
|
+
require 'lev'
|
33
|
+
|
34
|
+
module OpenStax
|
35
|
+
module Utilities
|
36
|
+
class OrderRelation
|
37
|
+
|
38
|
+
lev_routine transaction: :no_transaction
|
39
|
+
|
40
|
+
protected
|
41
|
+
|
42
|
+
def exec(*args)
|
43
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
44
|
+
relation = options[:relation] || args[0]
|
45
|
+
sortable_fields = options[:sortable_fields] || args[1]
|
46
|
+
order_by = options[:order_by] || args[2]
|
47
|
+
|
48
|
+
raise ArgumentError, 'You must specify a :relation option' \
|
49
|
+
if relation.nil?
|
50
|
+
raise ArgumentError, 'You must specify a :sortable_fields option' \
|
51
|
+
if sortable_fields.nil?
|
52
|
+
|
53
|
+
# Convert sortable_fields to Hash if it's an Array
|
54
|
+
sortable_fields = Hash[*sortable_fields.collect{|s| [s.to_s, s]}] \
|
55
|
+
if sortable_fields.is_a? Array
|
56
|
+
|
57
|
+
# Ordering
|
58
|
+
order_bys = sanitize_order_bys(sortable_fields, order_by)
|
59
|
+
outputs[:items] = relation.order(order_bys)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Returns an order_by Object
|
63
|
+
def sanitize_order_by(sortable_fields, field = nil, dir = nil)
|
64
|
+
sanitized_field = sortable_fields[field.to_s.downcase] || \
|
65
|
+
sortable_fields.values.first
|
66
|
+
sanitized_dir = dir.to_s.downcase == 'desc' ? :desc : :asc
|
67
|
+
case sanitized_field
|
68
|
+
when Symbol
|
69
|
+
{sanitized_field => sanitized_dir}
|
70
|
+
when Arel::Attributes::Attribute
|
71
|
+
sanitized_field.send sanitized_dir
|
72
|
+
else
|
73
|
+
"#{sanitized_field.to_s} #{sanitized_dir.to_s.upcase}"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Returns an Array of order_by Objects
|
78
|
+
def sanitize_order_bys(sortable_fields, order_bys = nil)
|
79
|
+
obs = case order_bys
|
80
|
+
when Array
|
81
|
+
order_bys.collect do |ob|
|
82
|
+
case ob
|
83
|
+
when Hash
|
84
|
+
sanitize_order_by(sortable_fields, ob.keys.first, ob.values.first)
|
85
|
+
when Array
|
86
|
+
sanitize_order_by(sortable_fields, ob.first, ob.second)
|
87
|
+
else
|
88
|
+
sanitize_order_by(sortable_fields, ob)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
when Hash
|
92
|
+
order_bys.collect { |k, v| sanitize_order_by(sortable_fields, k, v) }
|
93
|
+
else
|
94
|
+
order_bys.to_s.split(',').collect do |ob|
|
95
|
+
fd = ob.split(' ')
|
96
|
+
sanitize_order_by(sortable_fields, fd.first, fd.second)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
obs.blank? ? sanitize_order_by(sortable_fields) : obs
|
100
|
+
end
|
101
|
+
|
102
|
+
end
|
103
|
+
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
# Database-agnostic routine for keyword searching
|
2
|
+
# and ordering, limiting and paginating search results
|
3
|
+
#
|
4
|
+
# Searches, orders, imposes a maximum number or records and paginates
|
5
|
+
# a given relation using the appropriate routines and user-supplied
|
6
|
+
# parameters from the params hash
|
7
|
+
#
|
8
|
+
# See the search_routine.rb, order_routine.rb and
|
9
|
+
# limit_and_paginate_routine.rb files for more information
|
10
|
+
#
|
11
|
+
# Callers must provide the search_relation, search_proc and
|
12
|
+
# sortable_fields arguments and may provide the max_items argument
|
13
|
+
#
|
14
|
+
# Users must provide the q (query) argument and may provide
|
15
|
+
# the order_by, per_page and page arguments in the params hash
|
16
|
+
# Users must also be authorized to search the base class of the search_routine
|
17
|
+
#
|
18
|
+
# Required arguments:
|
19
|
+
#
|
20
|
+
# Developer-supplied:
|
21
|
+
#
|
22
|
+
# relation - the initial ActiveRecord::Relation to start searching on
|
23
|
+
# search_proc - a Proc passed to keyword_search's `search` method
|
24
|
+
# it receives keyword_search's `with` object as argument
|
25
|
+
# this proc must define the `keyword` blocks for keyword_search
|
26
|
+
# the relation to be scoped is contained in the @items instance variable
|
27
|
+
# the `to_string_array` helper can help with
|
28
|
+
# parsing strings from the query
|
29
|
+
#
|
30
|
+
# sortable_fields - list of fields that can appear in the order_by argument
|
31
|
+
# can be a Hash that maps field names to database columns
|
32
|
+
# or an Array of Strings
|
33
|
+
# invalid fields in order_by will be replaced with
|
34
|
+
# the first field listed here, in :asc order
|
35
|
+
#
|
36
|
+
# User or UI-supplied:
|
37
|
+
#
|
38
|
+
# params[:query] - a String that follows the keyword format
|
39
|
+
# Keywords have the format keyword:value
|
40
|
+
# Keywords can also be negated with -, as in -keyword:value
|
41
|
+
# Values are comma-separated; keywords are space-separated
|
42
|
+
#
|
43
|
+
# Optional arguments:
|
44
|
+
#
|
45
|
+
# Developer-supplied (recommended to prevent scraping):
|
46
|
+
#
|
47
|
+
# max_items - the maximum number of matching items allowed to be returned
|
48
|
+
# no results will be returned if this number is exceeded,
|
49
|
+
# but the total result count will still be returned
|
50
|
+
# applies even if pagination is enabled
|
51
|
+
# default: nil (disabled)
|
52
|
+
#
|
53
|
+
# User or UI-supplied:
|
54
|
+
#
|
55
|
+
# params[:order_by] - list of fields to order by, with optional sort directions
|
56
|
+
# can be (an Array of) Hashes, or Strings
|
57
|
+
# default: {sortable_fields.values.first => :asc}
|
58
|
+
#
|
59
|
+
# params[:per_page] - the maximum number of search results per page
|
60
|
+
# default: nil (disabled)
|
61
|
+
# params[:page] - the number of the page to return
|
62
|
+
# default: 1
|
63
|
+
#
|
64
|
+
# This handler's output contains:
|
65
|
+
#
|
66
|
+
# outputs[:total_count] - the total number of items that matched the query
|
67
|
+
# outputs[:items] - the relation returned by the search routines
|
68
|
+
|
69
|
+
require 'lev'
|
70
|
+
|
71
|
+
module OpenStax
|
72
|
+
module Utilities
|
73
|
+
class SearchAndOrganizeRelation
|
74
|
+
|
75
|
+
lev_routine transaction: :no_transaction
|
76
|
+
|
77
|
+
uses_routine SearchRelation,
|
78
|
+
as: :search
|
79
|
+
uses_routine OrderRelation,
|
80
|
+
as: :order
|
81
|
+
uses_routine LimitAndPaginateRelation,
|
82
|
+
as: :limit_and_paginate,
|
83
|
+
errors_are_fatal: false,
|
84
|
+
translations: { inputs: { type: :verbatim },
|
85
|
+
outputs: { type: :verbatim } }
|
86
|
+
|
87
|
+
protected
|
88
|
+
|
89
|
+
def exec(*args, &search_proc)
|
90
|
+
|
91
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
92
|
+
relation = options[:relation] || args[0]
|
93
|
+
sortable_fields = options[:sortable_fields] || args[1]
|
94
|
+
params = options[:params] || args[2]
|
95
|
+
search_proc ||= options[:search_proc] || args[3]
|
96
|
+
max_items = options[:max_items] || nil
|
97
|
+
|
98
|
+
raise ArgumentError, 'You must specify a :relation option' \
|
99
|
+
if relation.nil?
|
100
|
+
raise ArgumentError, 'You must specify a :sortable_fields option' \
|
101
|
+
if sortable_fields.nil?
|
102
|
+
raise ArgumentError, 'You must specify a :params option' if params.nil?
|
103
|
+
raise ArgumentError, 'You must specify a block or :search_proc option' \
|
104
|
+
if search_proc.nil?
|
105
|
+
|
106
|
+
query = params[:query] || params[:q]
|
107
|
+
order_by = params[:order_by] || params[:ob]
|
108
|
+
per_page = params[:per_page] || params[:pp]
|
109
|
+
page = params[:page] || params[:p]
|
110
|
+
|
111
|
+
items = run(:search, relation: relation, search_proc: search_proc,
|
112
|
+
query: query).outputs[:items]
|
113
|
+
|
114
|
+
items = run(:order, relation: items, sortable_fields: sortable_fields,
|
115
|
+
order_by: order_by).outputs[:items]
|
116
|
+
|
117
|
+
if max_items.nil? && per_page.nil? && page.nil?
|
118
|
+
outputs[:items] = items
|
119
|
+
outputs[:total_count] = items.count
|
120
|
+
return
|
121
|
+
end
|
122
|
+
|
123
|
+
run(:limit_and_paginate, relation: items, max_items: max_items,
|
124
|
+
per_page: per_page, page: page)
|
125
|
+
end
|
126
|
+
|
127
|
+
end
|
128
|
+
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
# Database-agnostic keyword searching routine
|
2
|
+
#
|
3
|
+
# Filters a relation based on a search Proc and a query String
|
4
|
+
# See https://github.com/bruce/keyword_search for more information
|
5
|
+
# about these arguments
|
6
|
+
#
|
7
|
+
# Callers of this routine provide the search_proc, relation and query arguments
|
8
|
+
#
|
9
|
+
# Required arguments:
|
10
|
+
#
|
11
|
+
# Developer-supplied:
|
12
|
+
#
|
13
|
+
# relation - the initial ActiveRecord::Relation to start searching on
|
14
|
+
# search_proc - a Proc passed to keyword_search's `search` method
|
15
|
+
# it receives keyword_search's `with` object as argument
|
16
|
+
# this proc must define the `keyword` blocks for keyword_search
|
17
|
+
# the relation to be scoped is contained in the @items instance variable
|
18
|
+
# the `to_string_array` helper can help with
|
19
|
+
# parsing strings from the query
|
20
|
+
#
|
21
|
+
# User or developer-supplied:
|
22
|
+
#
|
23
|
+
# query - a String that follows the keyword format
|
24
|
+
# Keywords have the format keyword:value
|
25
|
+
# Keywords can also be negated with -, as in -keyword:value
|
26
|
+
# Values are comma-separated, while keywords are space-separated
|
27
|
+
#
|
28
|
+
# This routine's outputs contain:
|
29
|
+
#
|
30
|
+
# outputs[:items] - a relation with records that match the query terms
|
31
|
+
|
32
|
+
require 'lev'
|
33
|
+
require 'keyword_search'
|
34
|
+
|
35
|
+
module OpenStax
|
36
|
+
module Utilities
|
37
|
+
class SearchRelation
|
38
|
+
|
39
|
+
lev_routine transaction: :no_transaction
|
40
|
+
|
41
|
+
protected
|
42
|
+
|
43
|
+
def exec(*args, &search_proc)
|
44
|
+
|
45
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
46
|
+
@items = options[:relation] || args[0]
|
47
|
+
query = options[:query] || args[1]
|
48
|
+
search_proc ||= options[:search_proc] || args[2]
|
49
|
+
|
50
|
+
raise ArgumentError, 'You must specify a :relation option' if @items.nil?
|
51
|
+
raise ArgumentError, 'You must specify a block or :search_proc option' \
|
52
|
+
if search_proc.nil?
|
53
|
+
|
54
|
+
# Scoping
|
55
|
+
|
56
|
+
::KeywordSearch.search(query.to_s) do |with|
|
57
|
+
instance_exec(with, &search_proc)
|
58
|
+
end
|
59
|
+
|
60
|
+
outputs[:items] = @items
|
61
|
+
end
|
62
|
+
|
63
|
+
# Parses a keyword string into an array of strings
|
64
|
+
# User-supplied wildcards are removed and strings are split on commas
|
65
|
+
# Then wildcards are appended or prepended if the append_wildcard or
|
66
|
+
# prepend_wildcard options are specified
|
67
|
+
def to_string_array(str, options = {})
|
68
|
+
sa = case str
|
69
|
+
when Array
|
70
|
+
str.collect{|name| name.gsub('%', '').split(',')}.flatten
|
71
|
+
else
|
72
|
+
str.to_s.gsub('%', '').split(',')
|
73
|
+
end
|
74
|
+
sa = sa.collect{|str| "#{str}%"} if options[:append_wildcard]
|
75
|
+
sa = sa.collect{|str| "%#{str}"} if options[:prepend_wildcard]
|
76
|
+
sa
|
77
|
+
end
|
78
|
+
|
79
|
+
# Parses a keyword string into an array of numbers
|
80
|
+
# User-supplied wildcards are removed and strings are split on commas
|
81
|
+
# Only numbers are returned
|
82
|
+
def to_number_array(str)
|
83
|
+
to_string_array(str).collect{|s| Integer(s) rescue nil}.compact
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
87
|
+
|
88
|
+
end
|
89
|
+
end
|
@@ -1,7 +1,9 @@
|
|
1
|
-
#
|
1
|
+
# Convenience constants for testing the search routines
|
2
2
|
|
3
|
-
class SearchUsers
|
4
|
-
|
3
|
+
class SearchUsers
|
4
|
+
RELATION = User.unscoped
|
5
|
+
|
6
|
+
SEARCH_PROC = lambda { |with|
|
5
7
|
with.keyword :username do |names|
|
6
8
|
snames = to_string_array(names, append_wildcard: true)
|
7
9
|
@items = @items.where{username.like_any snames}
|
@@ -17,5 +19,8 @@ class SearchUsers < OpenStax::Utilities::AbstractKeywordSearchRoutine
|
|
17
19
|
@items = @items.where{name.like_any snames}
|
18
20
|
end
|
19
21
|
}
|
20
|
-
|
22
|
+
|
23
|
+
SORTABLE_FIELDS = {'id' => :id, 'name' => :name, 'created_at' => :created_at}
|
24
|
+
|
25
|
+
MAX_ITEMS = 50
|
21
26
|
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'rails_helper'
|
2
|
+
|
3
|
+
module OpenStax
|
4
|
+
module Utilities
|
5
|
+
describe SearchRelation do
|
6
|
+
|
7
|
+
let!(:john_doe) { FactoryGirl.create :user, name: "John Doe",
|
8
|
+
username: "doejohn",
|
9
|
+
email: "john@doe.com" }
|
10
|
+
|
11
|
+
let!(:jane_doe) { FactoryGirl.create :user, name: "Jane Doe",
|
12
|
+
username: "doejane",
|
13
|
+
email: "jane@doe.com" }
|
14
|
+
|
15
|
+
let!(:jack_doe) { FactoryGirl.create :user, name: "Jack Doe",
|
16
|
+
username: "doejack",
|
17
|
+
email: "jack@doe.com" }
|
18
|
+
|
19
|
+
before(:each) do
|
20
|
+
100.times do
|
21
|
+
FactoryGirl.create(:user)
|
22
|
+
end
|
23
|
+
|
24
|
+
@relation = User.unscoped
|
25
|
+
end
|
26
|
+
|
27
|
+
it "returns nothing if too many results" do
|
28
|
+
routine = LimitAndPaginateRelation.call(relation: @relation,
|
29
|
+
max_items: 10)
|
30
|
+
outputs = routine.outputs
|
31
|
+
errors = routine.errors
|
32
|
+
expect(outputs).not_to be_empty
|
33
|
+
expect(outputs[:total_count]).to eq User.count
|
34
|
+
expect(outputs[:items]).to be_empty
|
35
|
+
expect(errors).not_to be_empty
|
36
|
+
expect(errors.first.code).to eq :too_many_items
|
37
|
+
end
|
38
|
+
|
39
|
+
it "paginates results" do
|
40
|
+
all_items = @relation.to_a
|
41
|
+
|
42
|
+
items = LimitAndPaginateRelation.call(relation: @relation,
|
43
|
+
per_page: 20).outputs[:items]
|
44
|
+
expect(items.limit(nil).offset(nil).count).to eq all_items.count
|
45
|
+
expect(items.limit(nil).offset(nil).to_a).to eq all_items
|
46
|
+
expect(items.count).to eq 20
|
47
|
+
expect(items.to_a).to eq all_items[0..19]
|
48
|
+
|
49
|
+
for page in 1..5
|
50
|
+
items = LimitAndPaginateRelation.call(relation: @relation,
|
51
|
+
page: page,
|
52
|
+
per_page: 20)
|
53
|
+
.outputs[:items]
|
54
|
+
expect(items.limit(nil).offset(nil).count).to eq all_items.count
|
55
|
+
expect(items.limit(nil).offset(nil).to_a).to eq all_items
|
56
|
+
expect(items.count).to eq 20
|
57
|
+
expect(items.to_a).to eq all_items.slice(20*(page-1), 20)
|
58
|
+
end
|
59
|
+
|
60
|
+
items = LimitAndPaginateRelation.call(relation: @relation,
|
61
|
+
page: 1000,
|
62
|
+
per_page: 20)
|
63
|
+
.outputs[:items]
|
64
|
+
expect(items.limit(nil).offset(nil).count).to eq all_items.count
|
65
|
+
expect(items.limit(nil).offset(nil).to_a).to eq all_items
|
66
|
+
expect(items.count).to eq 0
|
67
|
+
expect(items.to_a).to be_empty
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'rails_helper'
|
2
|
+
|
3
|
+
module OpenStax
|
4
|
+
module Utilities
|
5
|
+
describe SearchRelation do
|
6
|
+
|
7
|
+
let!(:john_doe) { FactoryGirl.create :user, name: "John Doe",
|
8
|
+
username: "doejohn",
|
9
|
+
email: "john@doe.com" }
|
10
|
+
|
11
|
+
let!(:jane_doe) { FactoryGirl.create :user, name: "Jane Doe",
|
12
|
+
username: "doejane",
|
13
|
+
email: "jane@doe.com" }
|
14
|
+
|
15
|
+
let!(:jack_doe) { FactoryGirl.create :user, name: "Jack Doe",
|
16
|
+
username: "doejack",
|
17
|
+
email: "jack@doe.com" }
|
18
|
+
|
19
|
+
before(:each) do
|
20
|
+
100.times do
|
21
|
+
FactoryGirl.create(:user)
|
22
|
+
end
|
23
|
+
|
24
|
+
@relation = User.where{username.like 'doe%'}
|
25
|
+
end
|
26
|
+
|
27
|
+
it "orders results by multiple fields in different directions" do
|
28
|
+
items = OrderRelation.call(relation: @relation,
|
29
|
+
sortable_fields: SearchUsers::SORTABLE_FIELDS,
|
30
|
+
order_by: 'cReAtEd_At AsC, iD')
|
31
|
+
.outputs[:items]
|
32
|
+
expect(items).to include(john_doe)
|
33
|
+
expect(items).to include(jane_doe)
|
34
|
+
expect(items).to include(jack_doe)
|
35
|
+
john_index = items.index(john_doe)
|
36
|
+
jane_index = items.index(jane_doe)
|
37
|
+
jack_index = items.index(jack_doe)
|
38
|
+
expect(jane_index).to be > john_index
|
39
|
+
expect(jack_index).to be > jane_index
|
40
|
+
|
41
|
+
items = OrderRelation.call(relation: @relation,
|
42
|
+
sortable_fields: SearchUsers::SORTABLE_FIELDS,
|
43
|
+
order_by: 'CrEaTeD_aT dEsC, Id DeSc')
|
44
|
+
.outputs[:items]
|
45
|
+
expect(items).to include(john_doe)
|
46
|
+
expect(items).to include(jane_doe)
|
47
|
+
expect(items).to include(jack_doe)
|
48
|
+
john_index = items.index(john_doe)
|
49
|
+
jane_index = items.index(jane_doe)
|
50
|
+
jack_index = items.index(jack_doe)
|
51
|
+
expect(jane_index).to be < john_index
|
52
|
+
expect(jack_index).to be < jane_index
|
53
|
+
end
|
54
|
+
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -2,7 +2,14 @@ require 'rails_helper'
|
|
2
2
|
|
3
3
|
module OpenStax
|
4
4
|
module Utilities
|
5
|
-
describe
|
5
|
+
describe SearchAndOrganizeRelation do
|
6
|
+
|
7
|
+
OPTIONS = {
|
8
|
+
relation: SearchUsers::RELATION,
|
9
|
+
search_proc: SearchUsers::SEARCH_PROC,
|
10
|
+
sortable_fields: SearchUsers::SORTABLE_FIELDS,
|
11
|
+
max_items: SearchUsers::MAX_ITEMS
|
12
|
+
}
|
6
13
|
|
7
14
|
let!(:john_doe) { FactoryGirl.create :user, name: "John Doe",
|
8
15
|
username: "doejohn",
|
@@ -22,8 +29,9 @@ module OpenStax
|
|
22
29
|
end
|
23
30
|
end
|
24
31
|
|
25
|
-
it "filters results
|
26
|
-
items =
|
32
|
+
it "filters results" do
|
33
|
+
items = SearchAndOrganizeRelation.call(OPTIONS.merge(params: {
|
34
|
+
q: 'last_name:dOe'})).outputs[:items]
|
27
35
|
|
28
36
|
expect(items).to include(john_doe)
|
29
37
|
expect(items).to include(jane_doe)
|
@@ -31,11 +39,9 @@ module OpenStax
|
|
31
39
|
items.each do |item|
|
32
40
|
expect(item.name.downcase).to match(/\A[\w]* doe[\w]*\z/i)
|
33
41
|
end
|
34
|
-
end
|
35
42
|
|
36
|
-
|
37
|
-
|
38
|
-
.outputs[:items]
|
43
|
+
items = SearchAndOrganizeRelation.call(OPTIONS.merge(params: {
|
44
|
+
q: 'first_name:jOhN last_name:DoE'})).outputs[:items]
|
39
45
|
|
40
46
|
expect(items).to include(john_doe)
|
41
47
|
expect(items).not_to include(jane_doe)
|
@@ -43,11 +49,9 @@ module OpenStax
|
|
43
49
|
items.each do |item|
|
44
50
|
expect(item.name).to match(/\Ajohn[\w]* doe[\w]*\z/i)
|
45
51
|
end
|
46
|
-
end
|
47
52
|
|
48
|
-
|
49
|
-
|
50
|
-
.outputs[:items]
|
53
|
+
items = SearchAndOrganizeRelation.call(OPTIONS.merge(params: {
|
54
|
+
q: 'first_name:JoHn,JaNe last_name:dOe'})).outputs[:items]
|
51
55
|
|
52
56
|
expect(items).to include(john_doe)
|
53
57
|
expect(items).to include(jane_doe)
|
@@ -57,22 +61,10 @@ module OpenStax
|
|
57
61
|
end
|
58
62
|
end
|
59
63
|
|
60
|
-
it "
|
61
|
-
items =
|
62
|
-
|
63
|
-
|
64
|
-
expect(items).to include(john_doe)
|
65
|
-
expect(items).not_to include(jane_doe)
|
66
|
-
expect(items).not_to include(jack_doe)
|
67
|
-
items.each do |item|
|
68
|
-
expect(item.name.downcase).to match(/\Ajohn[\w]* doe[\w]*\z/i)
|
69
|
-
end
|
70
|
-
end
|
71
|
-
|
72
|
-
it "orders results by multiple fields in different directions" do
|
73
|
-
items = SearchUsers.call(User.unscoped, 'username:DoE',
|
74
|
-
order_by: 'cReAtEd_At AsC, iD')
|
75
|
-
.outputs[:items]
|
64
|
+
it "orders results" do
|
65
|
+
items = SearchAndOrganizeRelation.call(OPTIONS.merge(params: {
|
66
|
+
order_by: 'cReAtEd_At AsC, iD',
|
67
|
+
q: 'username:dOe'})).outputs[:items].to_a
|
76
68
|
expect(items).to include(john_doe)
|
77
69
|
expect(items).to include(jane_doe)
|
78
70
|
expect(items).to include(jack_doe)
|
@@ -81,13 +73,10 @@ module OpenStax
|
|
81
73
|
jack_index = items.index(jack_doe)
|
82
74
|
expect(jane_index).to be > john_index
|
83
75
|
expect(jack_index).to be > jane_index
|
84
|
-
items.each do |item|
|
85
|
-
expect(item.username).to match(/\Adoe[\w]*\z/i)
|
86
|
-
end
|
87
76
|
|
88
|
-
items =
|
89
|
-
|
90
|
-
|
77
|
+
items = SearchAndOrganizeRelation.call(OPTIONS.merge(params: {
|
78
|
+
order_by: 'CrEaTeD_aT dEsC, Id DeSc',
|
79
|
+
q: 'username:dOe'})).outputs[:items].to_a
|
91
80
|
expect(items).to include(john_doe)
|
92
81
|
expect(items).to include(jane_doe)
|
93
82
|
expect(items).to include(jack_doe)
|
@@ -96,31 +85,49 @@ module OpenStax
|
|
96
85
|
jack_index = items.index(jack_doe)
|
97
86
|
expect(jane_index).to be < john_index
|
98
87
|
expect(jack_index).to be < jane_index
|
99
|
-
|
100
|
-
|
101
|
-
|
88
|
+
end
|
89
|
+
|
90
|
+
it "returns nothing if too many results" do
|
91
|
+
routine = SearchAndOrganizeRelation.call(OPTIONS.merge(params: {
|
92
|
+
q: ''}))
|
93
|
+
outputs = routine.outputs
|
94
|
+
errors = routine.errors
|
95
|
+
expect(outputs).not_to be_empty
|
96
|
+
expect(outputs[:total_count]).to eq User.count
|
97
|
+
expect(outputs[:items]).to be_empty
|
98
|
+
expect(errors).not_to be_empty
|
99
|
+
expect(errors.first.code).to eq :too_many_items
|
102
100
|
end
|
103
101
|
|
104
102
|
it "paginates results" do
|
105
|
-
all_items = SearchUsers.
|
103
|
+
all_items = SearchUsers::RELATION.to_a
|
106
104
|
|
107
|
-
items =
|
108
|
-
|
105
|
+
items = SearchAndOrganizeRelation.call(OPTIONS
|
106
|
+
.except(:max_items)
|
107
|
+
.merge(params: {q: '',
|
108
|
+
per_page: 20})).outputs[:items]
|
109
|
+
expect(items.limit(nil).offset(nil).count).to eq all_items.length
|
109
110
|
expect(items.limit(nil).offset(nil).to_a).to eq all_items
|
110
111
|
expect(items.count).to eq 20
|
111
112
|
expect(items.to_a).to eq all_items[0..19]
|
112
113
|
|
113
114
|
for page in 1..5
|
114
|
-
items =
|
115
|
-
|
115
|
+
items = SearchAndOrganizeRelation.call(OPTIONS
|
116
|
+
.except(:max_items)
|
117
|
+
.merge(params: {q: '',
|
118
|
+
page: page,
|
119
|
+
per_page: 20})).outputs[:items]
|
116
120
|
expect(items.limit(nil).offset(nil).count).to eq all_items.count
|
117
121
|
expect(items.limit(nil).offset(nil).to_a).to eq all_items
|
118
122
|
expect(items.count).to eq 20
|
119
123
|
expect(items.to_a).to eq all_items.slice(20*(page-1), 20)
|
120
124
|
end
|
121
125
|
|
122
|
-
items =
|
123
|
-
|
126
|
+
items = SearchAndOrganizeRelation.call(OPTIONS
|
127
|
+
.except(:max_items)
|
128
|
+
.merge(params: {q: '',
|
129
|
+
page: 1000,
|
130
|
+
per_page: 20})).outputs[:items]
|
124
131
|
expect(items.limit(nil).offset(nil).count).to eq all_items.count
|
125
132
|
expect(items.limit(nil).offset(nil).to_a).to eq all_items
|
126
133
|
expect(items.count).to eq 0
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'rails_helper'
|
2
|
+
|
3
|
+
module OpenStax
|
4
|
+
module Utilities
|
5
|
+
describe SearchRelation do
|
6
|
+
|
7
|
+
let!(:john_doe) { FactoryGirl.create :user, name: "John Doe",
|
8
|
+
username: "doejohn",
|
9
|
+
email: "john@doe.com" }
|
10
|
+
|
11
|
+
let!(:jane_doe) { FactoryGirl.create :user, name: "Jane Doe",
|
12
|
+
username: "doejane",
|
13
|
+
email: "jane@doe.com" }
|
14
|
+
|
15
|
+
let!(:jack_doe) { FactoryGirl.create :user, name: "Jack Doe",
|
16
|
+
username: "doejack",
|
17
|
+
email: "jack@doe.com" }
|
18
|
+
|
19
|
+
before(:each) do
|
20
|
+
100.times do
|
21
|
+
FactoryGirl.create(:user)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
it "filters results based on one field" do
|
26
|
+
items = SearchRelation.call(relation: SearchUsers::RELATION,
|
27
|
+
search_proc: SearchUsers::SEARCH_PROC,
|
28
|
+
query: 'last_name:dOe').outputs[:items]
|
29
|
+
|
30
|
+
expect(items).to include(john_doe)
|
31
|
+
expect(items).to include(jane_doe)
|
32
|
+
expect(items).to include(jack_doe)
|
33
|
+
items.each do |item|
|
34
|
+
expect(item.name.downcase).to match(/\A[\w]* doe[\w]*\z/i)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
it "filters results based on multiple fields" do
|
39
|
+
items = SearchRelation.call(relation: SearchUsers::RELATION,
|
40
|
+
search_proc: SearchUsers::SEARCH_PROC,
|
41
|
+
query: 'first_name:jOhN last_name:DoE')
|
42
|
+
.outputs[:items]
|
43
|
+
|
44
|
+
expect(items).to include(john_doe)
|
45
|
+
expect(items).not_to include(jane_doe)
|
46
|
+
expect(items).not_to include(jack_doe)
|
47
|
+
items.each do |item|
|
48
|
+
expect(item.name).to match(/\Ajohn[\w]* doe[\w]*\z/i)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
it "filters results based on multiple keywords per field" do
|
53
|
+
items = SearchRelation.call(relation: SearchUsers::RELATION,
|
54
|
+
search_proc: SearchUsers::SEARCH_PROC,
|
55
|
+
query: 'first_name:JoHn,JaNe last_name:dOe')
|
56
|
+
.outputs[:items]
|
57
|
+
|
58
|
+
expect(items).to include(john_doe)
|
59
|
+
expect(items).to include(jane_doe)
|
60
|
+
expect(items).not_to include(jack_doe)
|
61
|
+
items.each do |item|
|
62
|
+
expect(item.name).to match(/\A[john|jane][\w]* doe[\w]*\z/i)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
it "filters scoped results" do
|
67
|
+
items = SearchRelation.call(relation: User.where{name.like 'jOhN%'},
|
68
|
+
search_proc: SearchUsers::SEARCH_PROC,
|
69
|
+
query: 'last_name:dOe').outputs[:items]
|
70
|
+
|
71
|
+
expect(items).to include(john_doe)
|
72
|
+
expect(items).not_to include(jane_doe)
|
73
|
+
expect(items).not_to include(jack_doe)
|
74
|
+
items.each do |item|
|
75
|
+
expect(item.name.downcase).to match(/\Ajohn[\w]* doe[\w]*\z/i)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: openstax_utilities
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 4.
|
4
|
+
version: 4.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- JP Slavinsky
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2014-
|
11
|
+
date: 2014-12-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -132,8 +132,10 @@ files:
|
|
132
132
|
- MIT-LICENSE
|
133
133
|
- README.md
|
134
134
|
- Rakefile
|
135
|
-
- app/
|
136
|
-
- app/routines/openstax/utilities/
|
135
|
+
- app/routines/openstax/utilities/limit_and_paginate_relation.rb
|
136
|
+
- app/routines/openstax/utilities/order_relation.rb
|
137
|
+
- app/routines/openstax/utilities/search_and_organize_relation.rb
|
138
|
+
- app/routines/openstax/utilities/search_relation.rb
|
137
139
|
- app/views/osu/shared/_action_list.html.erb
|
138
140
|
- lib/openstax/utilities/access.rb
|
139
141
|
- lib/openstax/utilities/access_policy.rb
|
@@ -164,7 +166,6 @@ files:
|
|
164
166
|
- spec/dummy/app/controllers/application_controller.rb
|
165
167
|
- spec/dummy/app/helpers/application_helper.rb
|
166
168
|
- spec/dummy/app/models/user.rb
|
167
|
-
- spec/dummy/app/routines/search_users.rb
|
168
169
|
- spec/dummy/app/views/layouts/application.html.erb
|
169
170
|
- spec/dummy/bin/bundle
|
170
171
|
- spec/dummy/bin/rails
|
@@ -184,6 +185,7 @@ files:
|
|
184
185
|
- spec/dummy/config/initializers/filter_parameter_logging.rb
|
185
186
|
- spec/dummy/config/initializers/inflections.rb
|
186
187
|
- spec/dummy/config/initializers/mime_types.rb
|
188
|
+
- spec/dummy/config/initializers/search_users.rb
|
187
189
|
- spec/dummy/config/initializers/session_store.rb
|
188
190
|
- spec/dummy/config/initializers/wrap_parameters.rb
|
189
191
|
- spec/dummy/config/locales/en.yml
|
@@ -196,10 +198,12 @@ files:
|
|
196
198
|
- spec/dummy/public/500.html
|
197
199
|
- spec/dummy/public/favicon.ico
|
198
200
|
- spec/factories/user.rb
|
199
|
-
- spec/handlers/openstax/utilities/keyword_search_handler_spec.rb
|
200
201
|
- spec/lib/openstax/utilities/access_policy_spec.rb
|
201
202
|
- spec/rails_helper.rb
|
202
|
-
- spec/routines/openstax/utilities/
|
203
|
+
- spec/routines/openstax/utilities/limit_and_paginate_relation_spec.rb
|
204
|
+
- spec/routines/openstax/utilities/order_relation_spec.rb
|
205
|
+
- spec/routines/openstax/utilities/search_and_organize_relation_spec.rb
|
206
|
+
- spec/routines/openstax/utilities/search_relation_spec.rb
|
203
207
|
- spec/spec_helper.rb
|
204
208
|
homepage: http://github.com/openstax/openstax_utilities
|
205
209
|
licenses:
|
@@ -232,7 +236,6 @@ test_files:
|
|
232
236
|
- spec/dummy/app/controllers/application_controller.rb
|
233
237
|
- spec/dummy/app/helpers/application_helper.rb
|
234
238
|
- spec/dummy/app/models/user.rb
|
235
|
-
- spec/dummy/app/routines/search_users.rb
|
236
239
|
- spec/dummy/app/views/layouts/application.html.erb
|
237
240
|
- spec/dummy/bin/bundle
|
238
241
|
- spec/dummy/bin/rails
|
@@ -251,6 +254,7 @@ test_files:
|
|
251
254
|
- spec/dummy/config/initializers/filter_parameter_logging.rb
|
252
255
|
- spec/dummy/config/initializers/inflections.rb
|
253
256
|
- spec/dummy/config/initializers/mime_types.rb
|
257
|
+
- spec/dummy/config/initializers/search_users.rb
|
254
258
|
- spec/dummy/config/initializers/session_store.rb
|
255
259
|
- spec/dummy/config/initializers/wrap_parameters.rb
|
256
260
|
- spec/dummy/config/locales/en.yml
|
@@ -266,8 +270,10 @@ test_files:
|
|
266
270
|
- spec/dummy/Rakefile
|
267
271
|
- spec/dummy/README.md
|
268
272
|
- spec/factories/user.rb
|
269
|
-
- spec/handlers/openstax/utilities/keyword_search_handler_spec.rb
|
270
273
|
- spec/lib/openstax/utilities/access_policy_spec.rb
|
271
274
|
- spec/rails_helper.rb
|
272
|
-
- spec/routines/openstax/utilities/
|
275
|
+
- spec/routines/openstax/utilities/limit_and_paginate_relation_spec.rb
|
276
|
+
- spec/routines/openstax/utilities/order_relation_spec.rb
|
277
|
+
- spec/routines/openstax/utilities/search_and_organize_relation_spec.rb
|
278
|
+
- spec/routines/openstax/utilities/search_relation_spec.rb
|
273
279
|
- spec/spec_helper.rb
|
@@ -1,95 +0,0 @@
|
|
1
|
-
# Database-agnostic keyword searching handler
|
2
|
-
#
|
3
|
-
# Keywords have the format keyword:value
|
4
|
-
# Keywords can also be negated with -, as in -keyword:value
|
5
|
-
# Values are comma-separated, while keywords are space-separated
|
6
|
-
# See https://github.com/bruce/keyword_search for more information
|
7
|
-
#
|
8
|
-
# Callers must pass the search_routine and search_relation options:
|
9
|
-
#
|
10
|
-
# Required:
|
11
|
-
#
|
12
|
-
# search_routine - the Lev::Routine that will handle the search
|
13
|
-
# search_relation - the ActiveRecord::Relation that will be searched
|
14
|
-
#
|
15
|
-
# Optional (recommended to prevent scraping):
|
16
|
-
#
|
17
|
-
# min_characters - the minimum number of characters allowed in the query
|
18
|
-
# only an error will be returned if the query has less
|
19
|
-
# than the minimum number of characters allowed
|
20
|
-
# default: nil (disabled)
|
21
|
-
#
|
22
|
-
# max_items - the maximum number of matching items allowed to be returned
|
23
|
-
# no results will be returned if this number is exceeded,
|
24
|
-
# but the total result count will still be returned
|
25
|
-
# applies even if pagination is enabled
|
26
|
-
# default: nil (disabled)
|
27
|
-
#
|
28
|
-
# This handler also expects the following parameters from the user or the UI:
|
29
|
-
#
|
30
|
-
# Required:
|
31
|
-
#
|
32
|
-
# q - the query itself, a String that follows the keyword format above
|
33
|
-
#
|
34
|
-
# Optional:
|
35
|
-
#
|
36
|
-
# order_by - a String used to order the search results - default: 'created_at ASC'
|
37
|
-
# per_page - the number of results returned per page - default: nil (disabled)
|
38
|
-
# page - the current page number - default: 1
|
39
|
-
#
|
40
|
-
# This handler's output contains:
|
41
|
-
#
|
42
|
-
# outputs[:total_count] - the total number of items that matched the query
|
43
|
-
# set even when no results are returned due to
|
44
|
-
# a query that is too short or too generic
|
45
|
-
# outputs[:items] - the array of objects returned by the search routine
|
46
|
-
#
|
47
|
-
# See spec/dummy/app/handlers/users_search.rb for an example search handler
|
48
|
-
|
49
|
-
require 'lev'
|
50
|
-
|
51
|
-
module OpenStax
|
52
|
-
module Utilities
|
53
|
-
class KeywordSearchHandler
|
54
|
-
|
55
|
-
lev_handler
|
56
|
-
|
57
|
-
protected
|
58
|
-
|
59
|
-
def authorized?
|
60
|
-
routine = options[:search_routine]
|
61
|
-
relation = options[:search_relation]
|
62
|
-
raise ArgumentError if routine.nil? || relation.nil?
|
63
|
-
OSU::AccessPolicy.action_allowed?(:search, caller, relation.base_class)
|
64
|
-
end
|
65
|
-
|
66
|
-
def handle
|
67
|
-
query = params[:q]
|
68
|
-
fatal_error(code: :query_blank,
|
69
|
-
message: 'You must provide a query parameter (q or query).') if query.nil?
|
70
|
-
|
71
|
-
min_characters = options[:min_characters]
|
72
|
-
fatal_error(code: :query_too_short,
|
73
|
-
message: "The provided query is too short (minimum #{
|
74
|
-
min_characters} characters).") \
|
75
|
-
if !min_characters.nil? && query.length < min_characters
|
76
|
-
|
77
|
-
routine = options[:search_routine]
|
78
|
-
relation = options[:search_relation]
|
79
|
-
items = run(routine, relation, query, params).outputs[:items]
|
80
|
-
|
81
|
-
outputs[:total_count] = items.limit(nil).offset(nil).count
|
82
|
-
|
83
|
-
max_items = options[:max_items]
|
84
|
-
fatal_error(code: :too_many_items,
|
85
|
-
message: "The number of matches exceeded the allowed limit of #{
|
86
|
-
max_items} matches. Please refine your query and try again.") \
|
87
|
-
if !max_items.nil? && outputs[:total_count] > max_items
|
88
|
-
|
89
|
-
outputs[:items] = items.to_a
|
90
|
-
end
|
91
|
-
|
92
|
-
end
|
93
|
-
|
94
|
-
end
|
95
|
-
end
|
@@ -1,158 +0,0 @@
|
|
1
|
-
# Database-agnostic keyword searching routine
|
2
|
-
#
|
3
|
-
# Keywords have the format keyword:value
|
4
|
-
# Keywords can also be negated with -, as in -keyword:value
|
5
|
-
# Values are comma-separated, while keywords are space-separated
|
6
|
-
# See https://github.com/bruce/keyword_search for more information
|
7
|
-
#
|
8
|
-
# Subclasses must set the search_proc and sortable_fields class variables
|
9
|
-
#
|
10
|
-
# search_proc - a proc passed to keyword_search's `search` method
|
11
|
-
# it receives keyword_search's `with` object as argument
|
12
|
-
# this proc must define the `keyword` blocks for keyword_search
|
13
|
-
# the relation to be scoped is contained in the @items instance variable
|
14
|
-
# the `to_string_array` helper can help with
|
15
|
-
# parsing strings from the query
|
16
|
-
#
|
17
|
-
# sortable_fields_map - a Hash that maps the lowercase names of fields
|
18
|
-
# which can be used to sort the results to symbols
|
19
|
-
# for their respective database columns
|
20
|
-
# keys are lowercase strings that should be allowed
|
21
|
-
# in options[:order_by]
|
22
|
-
# values are the corresponding database column names
|
23
|
-
# that will be passed to the order() method
|
24
|
-
# columns from other tables can be specified either
|
25
|
-
# through Arel attributes (Class.arel_table[:column])
|
26
|
-
# or through literal strings
|
27
|
-
#
|
28
|
-
# Callers of subclass routines provide a relation argument,
|
29
|
-
# a query argument and an options hash
|
30
|
-
#
|
31
|
-
# Required arguments:
|
32
|
-
#
|
33
|
-
# relation - the initial relation to start searching on
|
34
|
-
# query - a string that follows the keyword format above
|
35
|
-
#
|
36
|
-
# Options hash:
|
37
|
-
#
|
38
|
-
# Ordering:
|
39
|
-
#
|
40
|
-
# :order_by - list of fields to sort by, with optional sort directions
|
41
|
-
# can be a String, Array of Strings or Array of Hashes
|
42
|
-
# default: {:created_at => :asc}
|
43
|
-
#
|
44
|
-
# Pagination:
|
45
|
-
#
|
46
|
-
# :per_page - the maximum number of results per page - default: nil (disabled)
|
47
|
-
# :page - the page to return - default: 1
|
48
|
-
#
|
49
|
-
# This routine's output contains:
|
50
|
-
#
|
51
|
-
# outputs[:items] - an ActiveRecord::Relation that matches the query terms and options
|
52
|
-
#
|
53
|
-
# You can use the following expression to obtain the
|
54
|
-
# total count of records that matched the query terms:
|
55
|
-
#
|
56
|
-
# outputs[:items].limit(nil).offset(nil).count
|
57
|
-
#
|
58
|
-
# See spec/dummy/app/routines/search_users.rb for an example search routine
|
59
|
-
|
60
|
-
require 'lev'
|
61
|
-
require 'keyword_search'
|
62
|
-
|
63
|
-
module OpenStax
|
64
|
-
module Utilities
|
65
|
-
class AbstractKeywordSearchRoutine
|
66
|
-
|
67
|
-
lev_routine transaction: :no_transaction
|
68
|
-
|
69
|
-
protected
|
70
|
-
|
71
|
-
class_attribute :search_proc, :sortable_fields_map
|
72
|
-
|
73
|
-
def exec(relation, query, options = {})
|
74
|
-
raise NotImplementedError if search_proc.nil? || sortable_fields_map.nil?
|
75
|
-
|
76
|
-
raise ArgumentError \
|
77
|
-
unless relation.is_a?(ActiveRecord::Relation) && query.is_a?(String)
|
78
|
-
|
79
|
-
@items = relation
|
80
|
-
|
81
|
-
# Scoping
|
82
|
-
|
83
|
-
::KeywordSearch.search(query) do |with|
|
84
|
-
instance_exec(with, &search_proc)
|
85
|
-
end
|
86
|
-
|
87
|
-
# Ordering
|
88
|
-
|
89
|
-
order_bys = sanitize_order_bys(options[:order_by])
|
90
|
-
@items = @items.order(order_bys)
|
91
|
-
|
92
|
-
# Pagination
|
93
|
-
|
94
|
-
per_page = Integer(options[:per_page]) rescue nil
|
95
|
-
unless per_page.nil?
|
96
|
-
page = Integer(options[:page]) rescue 1
|
97
|
-
@items = @items.limit(per_page).offset(per_page*(page-1))
|
98
|
-
end
|
99
|
-
|
100
|
-
outputs[:items] = @items
|
101
|
-
end
|
102
|
-
|
103
|
-
def sanitize_order_by(field, dir = nil)
|
104
|
-
sanitized_field = sortable_fields_map[field.to_s.downcase] || :created_at
|
105
|
-
sanitized_dir = dir.to_s.downcase == 'desc' ? :desc : :asc
|
106
|
-
case sanitized_field
|
107
|
-
when Symbol
|
108
|
-
{sanitized_field => sanitized_dir}
|
109
|
-
when Arel::Attributes::Attribute
|
110
|
-
sanitized_field.send sanitized_dir
|
111
|
-
else
|
112
|
-
"#{sanitized_field.to_s} #{sanitized_dir.to_s.upcase}"
|
113
|
-
end
|
114
|
-
end
|
115
|
-
|
116
|
-
def sanitize_order_bys(order_bys)
|
117
|
-
case order_bys
|
118
|
-
when Array
|
119
|
-
order_bys.collect do |ob|
|
120
|
-
case ob
|
121
|
-
when Hash
|
122
|
-
sanitize_order_by(ob.keys.first, ob.values.first)
|
123
|
-
when Array
|
124
|
-
sanitize_order_by(ob.first, ob.second)
|
125
|
-
else
|
126
|
-
sanitize_order_by(ob)
|
127
|
-
end
|
128
|
-
end
|
129
|
-
when Hash
|
130
|
-
order_bys.collect { |k, v| sanitize_order_by(k, v) }
|
131
|
-
else
|
132
|
-
order_bys.to_s.split(',').collect do |ob|
|
133
|
-
fd = ob.split(' ')
|
134
|
-
sanitize_order_by(fd.first, fd.second)
|
135
|
-
end
|
136
|
-
end
|
137
|
-
end
|
138
|
-
|
139
|
-
# Parses a keyword string into an array of strings
|
140
|
-
# User-supplied wildcards are removed and strings are split on commas
|
141
|
-
# Then wildcards are appended or prepended if the append_wildcard or
|
142
|
-
# prepend_wildcard options are specified
|
143
|
-
def to_string_array(str, options = {})
|
144
|
-
sa = case str
|
145
|
-
when Array
|
146
|
-
str.collect{|name| name.gsub('%', '').split(',')}.flatten
|
147
|
-
else
|
148
|
-
str.to_s.gsub('%', '').split(',')
|
149
|
-
end
|
150
|
-
sa = sa.collect{|str| "#{str}%"} if options[:append_wildcard]
|
151
|
-
sa = sa.collect{|str| "%#{str}"} if options[:prepend_wildcard]
|
152
|
-
sa
|
153
|
-
end
|
154
|
-
|
155
|
-
end
|
156
|
-
|
157
|
-
end
|
158
|
-
end
|
@@ -1,126 +0,0 @@
|
|
1
|
-
require 'rails_helper'
|
2
|
-
|
3
|
-
module OpenStax
|
4
|
-
module Utilities
|
5
|
-
describe KeywordSearchHandler do
|
6
|
-
|
7
|
-
options = {
|
8
|
-
caller: FactoryGirl.create(:user),
|
9
|
-
search_routine: SearchUsers,
|
10
|
-
search_relation: User.unscoped,
|
11
|
-
max_items: 10,
|
12
|
-
min_characters: 3
|
13
|
-
}
|
14
|
-
|
15
|
-
let!(:john_doe) { FactoryGirl.create :user, name: "John Doe",
|
16
|
-
username: "doejohn",
|
17
|
-
email: "john@doe.com" }
|
18
|
-
|
19
|
-
let!(:jane_doe) { FactoryGirl.create :user, name: "Jane Doe",
|
20
|
-
username: "doejane",
|
21
|
-
email: "jane@doe.com" }
|
22
|
-
|
23
|
-
let!(:jack_doe) { FactoryGirl.create :user, name: "Jack Doe",
|
24
|
-
username: "doejack",
|
25
|
-
email: "jack@doe.com" }
|
26
|
-
|
27
|
-
before(:each) do
|
28
|
-
100.times do
|
29
|
-
FactoryGirl.create(:user)
|
30
|
-
end
|
31
|
-
|
32
|
-
DummyAccessPolicy.last_action = nil
|
33
|
-
DummyAccessPolicy.last_requestor = nil
|
34
|
-
DummyAccessPolicy.last_resource = nil
|
35
|
-
end
|
36
|
-
|
37
|
-
it "passes its params to the search routine and sets the total_count output" do
|
38
|
-
outputs = KeywordSearchHandler.call(options.merge(
|
39
|
-
params: {q: 'username:dOe'})).outputs
|
40
|
-
total_count = outputs[:total_count]
|
41
|
-
items = outputs[:items]
|
42
|
-
expect(DummyAccessPolicy.last_action).to eq :search
|
43
|
-
expect(DummyAccessPolicy.last_requestor).to eq options[:caller]
|
44
|
-
expect(DummyAccessPolicy.last_resource).to eq User
|
45
|
-
expect(total_count).to eq items.count
|
46
|
-
expect(items).to include(john_doe)
|
47
|
-
expect(items).to include(jane_doe)
|
48
|
-
expect(items).to include(jack_doe)
|
49
|
-
john_index = items.index(john_doe)
|
50
|
-
jane_index = items.index(jane_doe)
|
51
|
-
jack_index = items.index(jack_doe)
|
52
|
-
expect(jane_index).to be > john_index
|
53
|
-
expect(jack_index).to be > jane_index
|
54
|
-
items.each do |item|
|
55
|
-
expect(item.username).to match(/\Adoe[\w]*\z/i)
|
56
|
-
end
|
57
|
-
|
58
|
-
DummyAccessPolicy.last_action = nil
|
59
|
-
DummyAccessPolicy.last_requestor = nil
|
60
|
-
DummyAccessPolicy.last_resource = nil
|
61
|
-
outputs = KeywordSearchHandler.call(options.merge(
|
62
|
-
params: {order_by: 'cReAtEd_At DeSc, iD dEsC',
|
63
|
-
q: 'username:DoE'})).outputs
|
64
|
-
total_count = outputs[:total_count]
|
65
|
-
items = outputs[:items]
|
66
|
-
expect(DummyAccessPolicy.last_action).to eq :search
|
67
|
-
expect(DummyAccessPolicy.last_requestor).to eq options[:caller]
|
68
|
-
expect(DummyAccessPolicy.last_resource).to eq User
|
69
|
-
expect(total_count).to eq items.count
|
70
|
-
expect(items).to include(john_doe)
|
71
|
-
expect(items).to include(jane_doe)
|
72
|
-
expect(items).to include(jack_doe)
|
73
|
-
john_index = items.index(john_doe)
|
74
|
-
jane_index = items.index(jane_doe)
|
75
|
-
jack_index = items.index(jack_doe)
|
76
|
-
expect(jane_index).to be < john_index
|
77
|
-
expect(jack_index).to be < jane_index
|
78
|
-
items.each do |item|
|
79
|
-
expect(item.username).to match(/\Adoe[\w]*\z/i)
|
80
|
-
end
|
81
|
-
end
|
82
|
-
|
83
|
-
it "errors out if no query is provided" do
|
84
|
-
routine = KeywordSearchHandler.call(options.merge(params: {}))
|
85
|
-
outputs = routine.outputs
|
86
|
-
errors = routine.errors
|
87
|
-
expect(DummyAccessPolicy.last_action).to eq :search
|
88
|
-
expect(DummyAccessPolicy.last_requestor).to eq options[:caller]
|
89
|
-
expect(DummyAccessPolicy.last_resource).to eq User
|
90
|
-
expect(outputs).to be_empty
|
91
|
-
expect(errors).not_to be_empty
|
92
|
-
expect(errors.first.code).to eq :query_blank
|
93
|
-
end
|
94
|
-
|
95
|
-
it "errors out if the query is too short" do
|
96
|
-
routine = KeywordSearchHandler.call(options.merge(params: {q: 'a'}))
|
97
|
-
outputs = routine.outputs
|
98
|
-
errors = routine.errors
|
99
|
-
expect(DummyAccessPolicy.last_action).to eq :search
|
100
|
-
expect(DummyAccessPolicy.last_requestor).to eq options[:caller]
|
101
|
-
expect(DummyAccessPolicy.last_resource).to eq User
|
102
|
-
expect(outputs).to be_empty
|
103
|
-
expect(errors).not_to be_empty
|
104
|
-
expect(errors.first.code).to eq :query_too_short
|
105
|
-
end
|
106
|
-
|
107
|
-
it "errors out if too many items match" do
|
108
|
-
routine = KeywordSearchHandler.call(options.merge(
|
109
|
-
params: {
|
110
|
-
q: 'username:a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,0,1,2,3,4,5,6,7,8,9,-,_'
|
111
|
-
}))
|
112
|
-
outputs = routine.outputs
|
113
|
-
errors = routine.errors
|
114
|
-
expect(DummyAccessPolicy.last_action).to eq :search
|
115
|
-
expect(DummyAccessPolicy.last_requestor).to eq options[:caller]
|
116
|
-
expect(DummyAccessPolicy.last_resource).to eq User
|
117
|
-
expect(outputs).not_to be_empty
|
118
|
-
expect(outputs[:total_count]).to eq User.count
|
119
|
-
expect(outputs[:items]).to be_nil
|
120
|
-
expect(errors).not_to be_empty
|
121
|
-
expect(errors.first.code).to eq :too_many_items
|
122
|
-
end
|
123
|
-
|
124
|
-
end
|
125
|
-
end
|
126
|
-
end
|