mongoid_search 0.3.2 → 0.4.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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4f3da97e4dd6f65400548a1e6f12e957fe0c2d5c8a390e015cd8d20572474c73
4
+ data.tar.gz: 71423ee292ac1f9e53cb4ef4171d4309c25ca56895203bd53c6bc2d8641fb385
5
+ SHA512:
6
+ metadata.gz: 59b2511bea3017819e52c2633e8ac8cc19158e7da43042584b9fccc4f815b2a2e57fa4b1407e2ee5726d36710473f0cc9143b7b750586c802658869a0c6f7c23
7
+ data.tar.gz: 65bfb18a5361a333d815b92bafc34bbe5133c04b28c9e4540397605f98eb76123338119b2b5ee911b5074e85d68d84cc6547ab059845bd4698eb0b5beaaf2f02
data/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2009 Mauricio Zaffari
1
+ Copyright (c) 2009 Mauricio Zaffari, 2013 Semenyuk Dmitriy (additions)
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -1,168 +1,231 @@
1
- Mongoid Search
2
- ============
1
+ # Mongoid Search
3
2
 
4
- Mongoid Search is a simple full text search implementation for Mongoid ORM. It performs well for small data sets. If your searchable model is big (i.e. 1.000.000+ records), solr or sphinx may suit you better.
3
+ Mongoid Search is a simple full text search implementation for Mongoid ORM. It supports Mongoid 3, 4, 5 and 6 and performs well for small data sets. If your searchable model is big (i.e. 1.000.000+ records), [mongoid_fulltext](https://github.com/mongoid/mongoid_fulltext), ElasticSearch, Solr or Sphinx may suit you better.
5
4
 
6
- Installation
7
- --------
5
+ [![Build Status](https://travis-ci.org/mongoid/mongoid_search.svg?branch=master)](https://travis-ci.org/mongoid/mongoid_search)
8
6
 
9
- In your Gemfile:
10
-
11
- gem 'mongoid_search'
7
+ ## Installation
12
8
 
13
- If your project is still using mongoid 2.x.x, stick to mongoid_search 0.2.x:
9
+ In your Gemfile:
14
10
 
15
- gem 'mongoid_search', '~> 0.2.8'
11
+ ```ruby
12
+ gem 'mongoid_search'
13
+ ```
16
14
 
17
15
  Then:
18
16
 
19
- bundle install
20
-
21
- Examples
22
- --------
23
-
24
- class Product
25
- include Mongoid::Document
26
- include Mongoid::Search
27
- field :brand
28
- field :name
29
-
30
- has_many :tags
31
- belongs_to :category
32
-
33
- search_in :brand, :name, :tags => :name, :category => :name
34
- end
35
-
36
- class Tag
37
- include Mongoid::Document
38
- field :name
17
+ ```
18
+ bundle install
19
+ ```
20
+
21
+ ## Examples
22
+
23
+ ```ruby
24
+ class Product
25
+ include Mongoid::Document
26
+ include Mongoid::Search
27
+ field :brand
28
+ field :name
29
+ field :unit
30
+ field :info, type: Hash
31
+
32
+ has_many :tags
33
+ belongs_to :category
34
+
35
+ search_in :brand, :name, tags: :name, category: :name, info: %i[summary description]
36
+ search_in :unit, index: :_unit_keywords
37
+ end
38
+
39
+ class Tag
40
+ include Mongoid::Document
41
+ field :name
42
+
43
+ belongs_to :product
44
+ end
45
+
46
+ class Category
47
+ include Mongoid::Document
48
+ field :name
49
+
50
+ has_many :products
51
+ end
52
+ ```
53
+
54
+ Now when you save a product, you get a `_keywords` field automatically:
55
+
56
+ ```ruby
57
+ p = Product.new brand: 'Apple', name: 'iPhone', unit: 'kilogram', info: { summary: 'Info-summary', description: 'Info-description' }
58
+ p.tags << Tag.new(name: 'Amazing')
59
+ p.tags << Tag.new(name: 'Awesome')
60
+ p.tags << Tag.new(name: 'Superb')
61
+ p.save
62
+ # => true
63
+ p._keywords
64
+ # => ["amazing", "apple", "awesome", "iphone", "superb", "Info-summary", "Info-description"]
65
+ p._unit_keywords
66
+ # => ["kilogram"]
67
+ ```
68
+
69
+ Now you can run search, which will look in the `_keywords` field and return all matching results:
70
+
71
+ ```ruby
72
+ Product.full_text_search("apple iphone").size
73
+ # => 1
74
+ ```
75
+
76
+ You can also search in "virtual" fields by defining them as methods. This can be useful when you have a method with dynamic fields (i.e. variable schema).
77
+ ```ruby
78
+ class ModelWithDynamicFields
79
+
80
+ ...
81
+
82
+ search_in :search_data
83
+
84
+ def search_data
85
+ # concatenate all String fields' values
86
+ self.attributes.select{|k,v| v.is_a?(String) }.values.join(' ')
87
+ end
88
+ end
89
+ ```
90
+ Mongoid_search will run the method before save and use it's output to populate the `_keywords` field.
91
+
92
+ Of course, some models could have more than one index. For instance, two different searches with different fields, so you could even specify from which index should be searched:
93
+
94
+ ```ruby
95
+ Product.full_text_search("kilogram", index: :_unit_keywords).size
96
+ # => 1
97
+ ```
39
98
 
40
- belongs_to :product
41
- end
42
-
43
- class Category
44
- include Mongoid::Document
45
- field :name
46
-
47
- has_many :products
48
- end
49
-
50
- Now when you save a product, you get a _keywords field automatically:
51
-
52
- p = Product.new :brand => "Apple", :name => "iPhone"
53
- p.tags << Tag.new(:name => "Amazing")
54
- p.tags << Tag.new(:name => "Awesome")
55
- p.tags << Tag.new(:name => "Superb")
56
- p.save
57
- => true
58
- p._keywords
59
- => ["amazing", "apple", "awesome", "iphone", "superb"]
99
+ Note that the search is case insensitive, and accept partial searching too:
60
100
 
61
- Now you can run search, which will look in the _keywords field and return all matching results:
101
+ ```ruby
102
+ Product.full_text_search('ipho').size
103
+ # => 1
104
+ ```
62
105
 
63
- Product.full_text_search("apple iphone").size
64
- => 1
106
+ Assuming you have a category with multiple products you can use the following code to search for 'iphone' in products cheaper than $499.
65
107
 
66
- Note that the search is case insensitive, and accept partial searching too:
108
+ ```ruby
109
+ category.products.where(:price.lt => 499).full_text_search('iphone').asc(:price)
110
+ ```
67
111
 
68
- Product.full_text_search("ipho").size
69
- => 1
112
+ To index or reindex all existing records, run this rake task
70
113
 
71
- Assuming you have a category with multiple products you can use the following
72
- code to search for 'iphone' in products cheaper than $499
114
+ ```
115
+ $ rake mongoid_search:index
116
+ ```
73
117
 
74
- @category.products.where(:price.lt => 499).full_text_search('iphone').asc(:price)
118
+ ## Options
75
119
 
120
+ ### match
76
121
 
77
- Options
78
- -------
122
+ * `:any` - match any occurrence
123
+ * `:all` - match all occurrences
79
124
 
80
- match:
125
+ Default is `:any`.
81
126
 
82
- _:any_ - match any occurrence
127
+ ```ruby
128
+ Product.full_text_search('apple motorola', match: :any).size
129
+ # => 1
83
130
 
84
- _:all_ - match all ocurrences
131
+ Product.full_text_search('apple motorola', match: :all).size
132
+ # => 0
133
+ ```
85
134
 
86
- Default is _:any_.
135
+ ### allow\_empty\_search
87
136
 
88
- Product.full_text_search("apple motorola", match: :any).size
89
- => 1
137
+ * `true` - will return `Model.all`
138
+ * `false` - will return `[]`
90
139
 
91
- Product.full_text_search("apple motorola", match: :all).size
92
- => 0
140
+ Default is `false`.
93
141
 
94
- allow\_empty\_search:
142
+ ```ruby
143
+ Product.full_text_search('', allow_empty_search: true).size
144
+ # => 1
145
+ ```
95
146
 
96
- _true_ - will return Model.all
147
+ ### relevant_search
97
148
 
98
- _false_ - will return []
149
+ * `true` - adds relevance information to the results
150
+ * `false` - no relevance information
99
151
 
100
- Default is _false_.
152
+ Default is `false`.
101
153
 
102
- Product.full_text_search("", allow_empty_search: true).size
103
- => 1
154
+ ```ruby
155
+ Product.full_text_search('amazing apple', relevant_search: true)
156
+ # => [#<Product _id: 5016e7d16af54efe1c000001, _type: nil, brand: "Apple", name: "iPhone", attrs: nil, info: nil, category_id: nil, _keywords: ["amazing", "apple", "awesome", "iphone", "superb"], relevance: 2.0>]
157
+ ```
104
158
 
105
- relevant_search:
159
+ Please note that relevant_search will return an Array and not a Criteria object. The search method should always be called in the end of the method chain.
106
160
 
107
- _true_ - Adds relevance information to the results
161
+ ### index
108
162
 
109
- _false_ - No relevance information
163
+ Default is `_keywords`.
110
164
 
111
- Default is _false_.
165
+ ```ruby
166
+ Product.full_text_search('amazing apple', index: :_keywords)
167
+ # => [#<Product _id: 5016e7d16af54efe1c000001, _type: nil, brand: "Apple", name: "iPhone", unit: "l", attrs: nil, info: nil, category_id: nil, _keywords: ["amazing", "apple", "awesome", "iphone", "superb"], _unit_keywords: ["l"], relevance: 2.0>]
112
168
 
113
- Product.full_text_search('amazing apple', relevant_search: true)
114
- => [#<Product _id: 5016e7d16af54efe1c000001, _type: nil, brand: "Apple", name: "iPhone", attrs: nil, info: nil, category_id: nil, _keywords: ["amazing", "apple", "awesome", "iphone", "superb"], relevance: 2.0>]
169
+ Product.full_text_search('kg', index: :_unit_keywords)
170
+ # => [#<Product _id: 5016e7d16af54efe1c000001, _type: nil, brand: "Apple", name: "iPhone", unit: "kg", attrs: nil, info: nil, category_id: nil, _keywords: ["amazing", "apple", "awesome", "iphone", "superb"], _unit_keywords: ["kg"], relevance: 2.0>]
171
+ ```
115
172
 
116
- Please note that relevant_search will return an Array and not a Criteria object. The search method shoud always be called in the end of the method chain.
173
+ index enables to have two or more different searches, with different or same fields. It should be noted that indexes are exclusive per each one.
117
174
 
118
- Initializer
119
- -----------
175
+ ## Initializer
120
176
 
121
177
  Alternatively, you can create an initializer to setup those options:
122
178
 
123
- Mongoid::Search.setup do |config|
124
- ## Default matching type. Match :any or :all searched keywords
125
- config.match = :any
179
+ ```ruby
180
+ Mongoid::Search.setup do |config|
181
+ ## Default matching type. Match :any or :all searched keywords
182
+ config.match = :any
183
+
184
+ ## If true, an empty search will return all objects
185
+ config.allow_empty_search = false
126
186
 
127
- ## If true, an empty search will return all objects
128
- config.allow_empty_search = false
187
+ ## If true, will search with relevance information
188
+ config.relevant_search = false
129
189
 
130
- ## If true, will search with relevance information
131
- config.relevant_search = false
190
+ ## Stem keywords
191
+ config.stem_keywords = false
132
192
 
133
- ## Stem keywords
134
- config.stem_keywords = false
193
+ ## Add a custom proc returning strings to replace the default stemmer
194
+ # For example using ruby-stemmer:
195
+ # config.stem_proc = Proc.new { |word| Lingua.stemmer(word, :language => 'nl') }
135
196
 
136
- ## Add a custom proc returning strings to replace the default stemmer
137
- # For example using ruby-stemmer:
138
- # config.stem_proc = Proc.new { |word| Lingua.stemmer(word, :language => 'nl') }
197
+ ## Words to ignore
198
+ config.ignore_list = []
139
199
 
140
- ## Words to ignore
141
- config.ignore_list = []
200
+ ## An array of words
201
+ # config.ignore_list = %w{ a an to from as }
142
202
 
143
- ## An array of words
144
- # config.ignore_list = %w{ a an to from as }
203
+ ## Or from a file
204
+ # config.ignore_list = YAML.load(File.open(File.dirname(__FILE__) + '/config/ignorelist.yml'))["ignorelist"]
145
205
 
146
- ## Or from a file
147
- # config.ignore_list = YAML.load(File.open(File.dirname(__FILE__) + '/config/ignorelist.yml'))["ignorelist"]
206
+ ## Search using regex (slower)
207
+ config.regex_search = true
148
208
 
149
- ## Search using regex (slower)
150
- config.regex_search = true
209
+ ## Regex to search
151
210
 
152
- ## Regex to search
211
+ ## Match partial words on both sides (slower)
212
+ config.regex = Proc.new { |query| /#{query}/ }
153
213
 
154
- ## Match partial words on both sides (slower)
155
- config.regex = Proc.new { |query| /#{query}/ }
214
+ ## Match partial words on the beginning or in the end (slightly faster)
215
+ # config.regex = Proc.new { |query| /^#{query}/ }
216
+ # config.regex = Proc.new { |query| /#{query}$/ }
156
217
 
157
- ## Match partial words on the beginning or in the end (slightly faster)
158
- # config.regex = Proc.new { |query| /ˆ#{query}/ }
159
- # config.regex = Proc.new { |query| /#{query}$/ }
218
+ # Ligatures to be replaced
219
+ # http://en.wikipedia.org/wiki/Typographic_ligature
220
+ config.ligatures = { "œ"=>"oe", "æ"=>"ae" }
160
221
 
161
- # Ligatures to be replaced
162
- # http://en.wikipedia.org/wiki/Typographic_ligature
163
- config.ligatures = { "œ"=>"oe", "æ"=>"ae" }
222
+ # Strip symbols regex to be replaced. These symbols will be replaced by space
223
+ config.strip_symbols = /[._:;'\"`,?|+={}()!@#%^&*<>~\$\-\\\/\[\]]/
164
224
 
165
- # Minimum word size. Words smaller than it won't be indexed
166
- config.minimum_word_size = 2
167
- end
225
+ # Strip accents regex to be replaced. These sybols will be removed after strip_symbols replacing
226
+ config.strip_accents = /[^\s\p{Alnum}]/
168
227
 
228
+ # Minimum word size. Words smaller than it won't be indexed
229
+ config.minimum_word_size = 2
230
+ end
231
+ ```
data/Rakefile CHANGED
@@ -3,8 +3,11 @@ require 'rake'
3
3
 
4
4
  require 'rspec/core/rake_task'
5
5
  RSpec::Core::RakeTask.new(:spec) do |spec|
6
- spec.rspec_opts = ["-c", "-f progress"]
6
+ spec.rspec_opts = ['-c', '-f progress']
7
7
  spec.pattern = 'spec/**/*_spec.rb'
8
8
  end
9
9
 
10
- task :default => :spec
10
+ require 'rubocop/rake_task'
11
+ RuboCop::RakeTask.new(:rubocop)
12
+
13
+ task default: %i[rubocop spec]
@@ -1,7 +1,6 @@
1
- # encoding: utf-8
1
+ require 'mongoid_search/mongoid_search'
2
2
 
3
3
  require 'mongoid_search/railtie' if defined?(Rails)
4
- require 'mongoid_search/mongoid_search'
5
4
 
6
5
  module Mongoid::Search
7
6
  ## Default matching type. Match :any or :all searched keywords
@@ -22,7 +21,7 @@ module Mongoid::Search
22
21
 
23
22
  ## Stem procedure
24
23
  mattr_accessor :stem_proc
25
- @@stem_proc = Proc.new { |word| word.stem }
24
+ @@stem_proc = proc { |word| word.stem }
26
25
 
27
26
  ## Words to ignore
28
27
  mattr_accessor :ignore_list
@@ -42,25 +41,33 @@ module Mongoid::Search
42
41
  mattr_accessor :regex
43
42
 
44
43
  ## Match partial words on both sides (slower)
45
- @@regex = Proc.new { |query| /#{query}/ }
44
+ @@regex = proc { |query| /#{query}/ }
46
45
 
47
46
  ## Match partial words on the beginning or in the end (slightly faster)
48
- # @@regex = Proc.new { |query| /ˆ#{query}/ }
47
+ # @@regex = Proc.new { |query| /^#{query}/ }
49
48
  # @@regex = Proc.new { |query| /#{query}$/ }
50
49
 
51
50
  # Ligatures to be replaced
52
51
  # http://en.wikipedia.org/wiki/Typographic_ligature
53
52
  mattr_accessor :ligatures
54
- @@ligatures = { "œ"=>"oe", "æ"=>"ae" }
53
+ @@ligatures = { 'œ' => 'oe', 'æ' => 'ae', 'ꜵ' => 'ao' }
55
54
 
56
55
  # Minimum word size. Words smaller than it won't be indexed
57
56
  mattr_accessor :minimum_word_size
58
57
  @@minimum_word_size = 2
59
58
 
59
+ # Strip special symbols
60
+ mattr_accessor :strip_symbols
61
+ @@strip_symbols = /[._:;'\"`,?|+={}()!@#%^&*<>~\$\-\\\/\[\]]/
62
+
63
+ # Strip accents
64
+ mattr_accessor :strip_accents
65
+ @@strip_accents = /[^\s\p{Alnum}]/
66
+
60
67
  def self.setup
61
68
  yield self
62
69
  end
63
70
  end
64
71
 
65
72
  require 'mongoid_search/util'
66
- require 'mongoid_search/log'
73
+ require 'mongoid_search/log'
@@ -14,7 +14,8 @@ class Mongoid::Search::Log
14
14
  end
15
15
 
16
16
  private
17
+
17
18
  def self.colorize(text, code)
18
19
  "\033[#{code}m#{text}\033[0m"
19
20
  end
20
- end
21
+ end
@@ -1,11 +1,10 @@
1
1
  module Mongoid::Search
2
- def self.included(base)
3
- base.send(:cattr_accessor, :search_fields)
4
-
5
- base.extend ClassMethods
2
+ extend ActiveSupport::Concern
6
3
 
4
+ included do
5
+ cattr_accessor :search_fields
7
6
  @@classes ||= []
8
- @@classes << base
7
+ @@classes << self
9
8
  end
10
9
 
11
10
  def self.classes
@@ -16,17 +15,19 @@ module Mongoid::Search
16
15
  # Set a field or a number of fields as sources for search
17
16
  def search_in(*args)
18
17
  args, options = args_and_options(args)
19
- self.search_fields = (self.search_fields || []).concat args
18
+ set_search_fields(options[:index], args)
20
19
 
21
- field :_keywords, :type => Array
20
+ field options[:index], type: Array
22
21
 
23
- index({ :_keywords => 1 }, { :background => true })
22
+ index({ options[:index] => 1 }, background: true)
24
23
 
25
24
  before_save :set_keywords
26
25
  end
27
26
 
28
- def full_text_search(query, options={})
27
+ def full_text_search(query, options = {})
29
28
  options = extract_options(options)
29
+ attr_accessor :relevance if options[:relevant_search].eql? true
30
+
30
31
  return (options[:allow_empty_search] ? criteria.all : []) if query.blank?
31
32
 
32
33
  if options[:relevant_search]
@@ -43,33 +44,46 @@ module Mongoid::Search
43
44
  # Goes through all documents in the class that includes Mongoid::Search
44
45
  # and indexes the keywords.
45
46
  def index_keywords!
46
- all.each { |d| d.index_keywords! ? Log.green(".") : Log.red("F") }
47
+ all.each { |d| d.index_keywords! ? Log.green('.') : Log.red('F') }
47
48
  end
48
49
 
49
50
  private
51
+
52
+ def set_search_fields(index, fields)
53
+ self.search_fields ||= {}
54
+
55
+ (self.search_fields[index] ||= []).concat fields
56
+ end
57
+
50
58
  def query(keywords, options)
51
59
  keywords_hash = keywords.map do |kw|
52
- kw = Mongoid::Search.regex.call(kw) if Mongoid::Search.regex_search
53
- { :_keywords => kw }
60
+ if Mongoid::Search.regex_search
61
+ escaped_kw = Regexp.escape(kw)
62
+ kw = Mongoid::Search.regex.call(escaped_kw)
63
+ end
64
+
65
+ { options[:index] => kw }
54
66
  end
55
67
 
56
- criteria.send("#{(options[:match]).to_s}_of", *keywords_hash)
68
+ criteria.send("#{(options[:match])}_of", *keywords_hash)
57
69
  end
58
70
 
59
71
  def args_and_options(args)
60
72
  options = args.last.is_a?(Hash) &&
61
- [:match,
62
- :allow_empty_search,
63
- :relevant_search].include?(args.last.keys.first) ? args.pop : {}
73
+ %i[match
74
+ allow_empty_search
75
+ index
76
+ relevant_search].include?(args.last.keys.first) ? args.pop : {}
64
77
 
65
78
  [args, extract_options(options)]
66
79
  end
67
80
 
68
81
  def extract_options(options)
69
82
  {
70
- :match => options[:match] || Mongoid::Search.match,
71
- :allow_empty_search => options[:allow_empty_search] || Mongoid::Search.allow_empty_search,
72
- :relevant_search => options[:relevant_search] || Mongoid::Search.relevant_search
83
+ match: options[:match] || Mongoid::Search.match,
84
+ allow_empty_search: options[:allow_empty_search] || Mongoid::Search.allow_empty_search,
85
+ relevant_search: options[:relevant_search] || Mongoid::Search.relevant_search,
86
+ index: options[:index] || :_keywords
73
87
  }
74
88
  end
75
89
 
@@ -79,52 +93,57 @@ module Mongoid::Search
79
93
 
80
94
  def search_relevant(query, options)
81
95
  results_with_relevance(query, options).sort { |o| o['value'] }.map do |r|
82
-
83
- new(r['_id'].merge(:relevance => r['value'])) do |o|
96
+ new(r['_id'].merge(relevance: r['value'])) do |o|
84
97
  # Need to match the actual object
85
98
  o.instance_variable_set('@new_record', false)
86
99
  o._id = r['_id']['_id']
87
100
  end
88
-
89
101
  end
90
102
  end
91
103
 
92
104
  def results_with_relevance(query, options)
93
105
  keywords = Mongoid::Search::Util.normalize_keywords(query)
94
106
 
95
- map = %Q{
96
- function() {
97
- var entries = 0;
98
- for(i in keywords) {
99
- for(j in this._keywords) {
100
- if(this._keywords[j] == keywords[i]) {
101
- entries++;
107
+ map = %{
108
+ function() {
109
+ var entries = 0;
110
+ for(i in keywords) {
111
+ for(j in this.#{options[:index]}) {
112
+ if(this.#{options[:index]}[j] == keywords[i]) {
113
+ entries++;
114
+ }
102
115
  }
103
116
  }
117
+ if(entries > 0) {
118
+ emit(this, entries);
119
+ }
104
120
  }
105
- if(entries > 0) {
106
- emit(this, entries);
107
- }
108
- }
109
121
  }
110
122
 
111
- reduce = %Q{
112
- function(key, values) {
113
- return(values);
114
- }
123
+ reduce = %{
124
+ function(key, values) {
125
+ return(values);
126
+ }
115
127
  }
116
128
 
117
- query(keywords, options).map_reduce(map, reduce).scope(:keywords => keywords).out(:inline => 1)
129
+ query(keywords, options).map_reduce(map, reduce).scope(keywords: keywords).out(inline: 1)
118
130
  end
119
131
  end
120
132
 
121
133
  def index_keywords!
122
- update_attribute(:_keywords, set_keywords)
134
+ search_fields.map do |index, fields|
135
+ update_attribute(index, get_keywords(fields))
136
+ end
123
137
  end
124
138
 
125
- private
126
139
  def set_keywords
127
- self._keywords = Mongoid::Search::Util.keywords(self, self.search_fields).
128
- flatten.reject{|k| k.nil? || k.empty?}.uniq.sort
140
+ search_fields.each do |index, fields|
141
+ send("#{index}=", get_keywords(fields))
142
+ end
143
+ end
144
+
145
+ def get_keywords(fields)
146
+ Mongoid::Search::Util.keywords(self, fields)
147
+ .flatten.reject { |k| k.nil? || k.empty? }.uniq.sort
129
148
  end
130
149
  end