mongoid_fulltext 0.6.1 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +47 -0
  3. data/.rspec +1 -1
  4. data/.rubocop.yml +6 -0
  5. data/.rubocop_todo.yml +101 -0
  6. data/.travis.yml +11 -3
  7. data/CHANGELOG.md +9 -2
  8. data/Gemfile +19 -9
  9. data/LICENSE +1 -1
  10. data/README.md +12 -9
  11. data/Rakefile +9 -29
  12. data/lib/mongoid/full_text_search/version.rb +5 -0
  13. data/lib/mongoid/full_text_search.rb +372 -0
  14. data/lib/mongoid/indexable.rb +13 -0
  15. data/lib/mongoid/indexes.rb +13 -0
  16. data/lib/mongoid_fulltext.rb +1 -341
  17. data/mongoid_fulltext.gemspec +16 -82
  18. data/spec/models/accentless_artwork.rb +1 -1
  19. data/spec/models/advanced_artwork.rb +1 -1
  20. data/spec/models/basic_artwork.rb +0 -1
  21. data/spec/models/delayed_artwork.rb +1 -2
  22. data/spec/models/external_artist.rb +1 -2
  23. data/spec/models/external_artwork.rb +1 -2
  24. data/spec/models/external_artwork_no_fields_supplied.rb +2 -2
  25. data/spec/models/filtered_artist.rb +4 -4
  26. data/spec/models/filtered_artwork.rb +7 -7
  27. data/spec/models/filtered_other.rb +3 -3
  28. data/spec/models/hidden_dragon.rb +0 -1
  29. data/spec/models/multi_external_artwork.rb +3 -3
  30. data/spec/models/multi_field_artist.rb +1 -1
  31. data/spec/models/multi_field_artwork.rb +1 -1
  32. data/spec/models/partitioned_artist.rb +8 -9
  33. data/spec/models/russian_artwork.rb +2 -2
  34. data/spec/models/short_prefixes_artwork.rb +3 -4
  35. data/spec/models/stopwords_artwork.rb +3 -4
  36. data/spec/mongoid/full_text_search_spec.rb +752 -0
  37. data/spec/spec_helper.rb +11 -7
  38. metadata +27 -68
  39. data/VERSION +0 -1
  40. data/lib/mongoid_indexes.rb +0 -12
  41. data/spec/config/mongoid.yml +0 -6
  42. data/spec/mongoid/fulltext_spec.rb +0 -799
@@ -0,0 +1,13 @@
1
+ # hook onto model index creation to create related FT indexes
2
+ module Mongoid
3
+ module Indexable
4
+ module ClassMethods
5
+ alias_method :create_fulltext_indexes_hook, :create_indexes
6
+
7
+ def create_indexes
8
+ create_fulltext_indexes if respond_to?(:create_fulltext_indexes)
9
+ create_fulltext_indexes_hook
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # hook onto model index creation to create related FT indexes
2
+ module Mongoid
3
+ module Indexes
4
+ module ClassMethods
5
+ alias_method :create_fulltext_indexes_hook, :create_indexes
6
+
7
+ def create_indexes
8
+ create_fulltext_indexes if respond_to?(:create_fulltext_indexes)
9
+ create_fulltext_indexes_hook
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,341 +1 @@
1
- require 'mongoid_indexes'
2
- require 'unicode_utils'
3
- require 'cgi'
4
-
5
- module Mongoid::FullTextSearch
6
- extend ActiveSupport::Concern
7
-
8
- included do
9
- cattr_accessor :mongoid_fulltext_config
10
- end
11
-
12
- class UnspecifiedIndexError < StandardError; end
13
- class UnknownFilterQueryOperator < StandardError; end
14
-
15
- module ClassMethods
16
-
17
- def fulltext_search_in(*args)
18
- self.mongoid_fulltext_config = {} if self.mongoid_fulltext_config.nil?
19
- options = args.last.is_a?(Hash) ? args.pop : {}
20
- if options.has_key?(:index_name)
21
- index_name = options[:index_name]
22
- else
23
- index_name = 'mongoid_fulltext.index_%s_%s' % [self.name.downcase, self.mongoid_fulltext_config.count]
24
- end
25
-
26
- config = {
27
- :alphabet => 'abcdefghijklmnopqrstuvwxyz0123456789 ',
28
- :word_separators => "-_ \n\t",
29
- :ngram_width => 3,
30
- :max_ngrams_to_search => 6,
31
- :apply_prefix_scoring_to_all_words => true,
32
- :index_full_words => true,
33
- :index_short_prefixes => false,
34
- :max_candidate_set_size => 1000,
35
- :remove_accents => true,
36
- :reindex_immediately => true,
37
- :stop_words => Hash[['i', 'a', 's', 't', 'me', 'my', 'we', 'he', 'it', 'am', 'is', 'be', 'do', 'an', 'if',
38
- 'or', 'as', 'of', 'at', 'by', 'to', 'up', 'in', 'on', 'no', 'so', 'our', 'you', 'him',
39
- 'his', 'she', 'her', 'its', 'who', 'are', 'was', 'has', 'had', 'did', 'the', 'and',
40
- 'but', 'for', 'out', 'off', 'why', 'how', 'all', 'any', 'few', 'nor', 'not', 'own',
41
- 'too', 'can', 'don', 'now', 'ours', 'your', 'hers', 'they', 'them', 'what', 'whom',
42
- 'this', 'that', 'were', 'been', 'have', 'does', 'with', 'into', 'from', 'down', 'over',
43
- 'then', 'once', 'here', 'when', 'both', 'each', 'more', 'most', 'some', 'such', 'only',
44
- 'same', 'than', 'very', 'will', 'just', 'yours', 'their', 'which', 'these', 'those',
45
- 'being', 'doing', 'until', 'while', 'about', 'after', 'above', 'below', 'under',
46
- 'again', 'there', 'where', 'other', 'myself', 'itself', 'theirs', 'having', 'during',
47
- 'before', 'should', 'himself', 'herself', 'because', 'against', 'between', 'through',
48
- 'further', 'yourself', 'ourselves', 'yourselves', 'themselves'].map{ |x| [x,true] }]
49
- }
50
-
51
- config.update(options)
52
-
53
- args = [:to_s] if args.empty?
54
- config[:ngram_fields] = args
55
- config[:alphabet] = Hash[config[:alphabet].split('').map{ |ch| [ch,ch] }]
56
- config[:word_separators] = Hash[config[:word_separators].split('').map{ |ch| [ch,ch] }]
57
- self.mongoid_fulltext_config[index_name] = config
58
-
59
- before_save(:update_ngram_index) if config[:reindex_immediately]
60
- before_destroy :remove_from_ngram_index
61
- end
62
-
63
- def create_fulltext_indexes
64
- return unless self.mongoid_fulltext_config
65
- self.mongoid_fulltext_config.each_pair do |index_name, fulltext_config|
66
- fulltext_search_ensure_indexes(index_name, fulltext_config)
67
- end
68
- end
69
-
70
- def fulltext_search_ensure_indexes(index_name, config)
71
- db = collection.database
72
- coll = db[index_name]
73
-
74
- # The order of filters matters when the same index is used from two or more collections.
75
- filter_indexes = (config[:filters] || []).map do |key,value|
76
- ["filter_values.#{key}", 1]
77
- end.sort_by { |filter_index| filter_index[0] }
78
-
79
- index_definition = [['ngram', 1], ['score', -1]].concat(filter_indexes)
80
-
81
- # Since the definition of the index could have changed, we'll clean up by
82
- # removing any indexes that aren't on the exact.
83
- correct_keys = index_definition.map{ |field_def| field_def[0] }
84
- all_filter_keys = filter_indexes.map{ |field_def| field_def[0] }
85
- coll.indexes.each do |idef|
86
- keys = idef['key'].keys
87
- next if !keys.member?('ngram')
88
- all_filter_keys |= keys.find_all{ |key| key.starts_with?('filter_values.') }
89
- if keys & correct_keys != correct_keys
90
- Mongoid.logger.info "Dropping #{idef['name']} [#{keys & correct_keys} <=> #{correct_keys}]" if Mongoid.logger
91
- coll.indexes.drop(idef['key'])
92
- end
93
- end
94
-
95
- if all_filter_keys.length > filter_indexes.length
96
- filter_indexes = all_filter_keys.map {|key| [key, 1] }.sort_by { |filter_index| filter_index[0] }
97
- index_definition = [['ngram', 1], ['score', -1]].concat(filter_indexes)
98
- end
99
-
100
- Mongoid.logger.info "Ensuring fts_index on #{coll.name}: #{index_definition}" if Mongoid.logger
101
- coll.indexes.create(Hash[index_definition], { :name => 'fts_index' })
102
-
103
- Mongoid.logger.info "Ensuring document_id index on #{coll.name}" if Mongoid.logger
104
- coll.indexes.create('document_id' => 1) # to make removes fast
105
- end
106
-
107
- def fulltext_search(query_string, options={})
108
- max_results = options.has_key?(:max_results) ? options.delete(:max_results) : 10
109
- return_scores = options.has_key?(:return_scores) ? options.delete(:return_scores) : false
110
- if self.mongoid_fulltext_config.count > 1 and !options.has_key?(:index)
111
- error_message = '%s is indexed by multiple full-text indexes. You must specify one by passing an :index_name parameter'
112
- raise UnspecifiedIndexError, error_message % self.name, caller
113
- end
114
- index_name = options.has_key?(:index) ? options.delete(:index) : self.mongoid_fulltext_config.keys.first
115
-
116
- # Options hash should only contain filters after this point
117
-
118
- ngrams = all_ngrams(query_string, self.mongoid_fulltext_config[index_name])
119
- return [] if ngrams.empty?
120
-
121
- # For each ngram, construct the query we'll use to pull index documents and
122
- # get a count of the number of index documents containing that n-gram
123
- ordering = {'score' => -1}
124
- limit = self.mongoid_fulltext_config[index_name][:max_candidate_set_size]
125
- coll = collection.database[index_name]
126
- cursors = ngrams.map do |ngram|
127
- query = {'ngram' => ngram[0]}
128
- query.update(map_query_filters options)
129
- count = coll.find(query).count
130
- {:ngram => ngram, :count => count, :query => query}
131
- end.sort!{ |record1, record2| record1[:count] <=> record2[:count] }
132
-
133
- # Using the queries we just constructed and the n-gram frequency counts we
134
- # just computed, pull in about *:max_candidate_set_size* candidates by
135
- # considering the n-grams in order of increasing frequency. When we've
136
- # spent all *:max_candidate_set_size* candidates, pull the top-scoring
137
- # *max_results* candidates for each remaining n-gram.
138
- results_so_far = 0
139
- candidates_list = cursors.map do |doc|
140
- next if doc[:count] == 0
141
- query_result = coll.find(doc[:query])
142
- if results_so_far >= limit
143
- query_result = query_result.sort(ordering).limit(max_results)
144
- elsif doc[:count] > limit - results_so_far
145
- query_result = query_result.sort(ordering).limit(limit - results_so_far)
146
- end
147
- results_so_far += doc[:count]
148
- ngram_score = ngrams[doc[:ngram][0]]
149
- Hash[query_result.map do |candidate|
150
- [candidate['document_id'],
151
- {:clazz => candidate['class'], :score => candidate['score'] * ngram_score}]
152
- end]
153
- end.compact
154
-
155
- # Finally, score all candidates by matching them up with other candidates that are
156
- # associated with the same document. This is similar to how you might process a
157
- # boolean AND query, except that with an AND query, you'd stop after considering
158
- # the first candidate list and matching its candidates up with candidates from other
159
- # lists, whereas here we want the search to be a little fuzzier so we'll run through
160
- # all candidate lists, removing candidates as we match them up.
161
- all_scores = []
162
- while !candidates_list.empty?
163
- candidates = candidates_list.pop
164
- scores = candidates.map do |candidate_id, data|
165
- {:id => candidate_id,
166
- :clazz => data[:clazz],
167
- :score => data[:score] + candidates_list.map{ |others| (others.delete(candidate_id) || {:score => 0})[:score] }.sum
168
- }
169
- end
170
- all_scores.concat(scores)
171
- end
172
- all_scores.sort!{ |document1, document2| -document1[:score] <=> -document2[:score] }
173
- instantiate_mapreduce_results(all_scores[0..max_results-1], { :return_scores => return_scores })
174
- end
175
-
176
- def instantiate_mapreduce_result(result)
177
- result[:clazz].constantize.find(result[:id])
178
- end
179
-
180
- def instantiate_mapreduce_results(results, options)
181
- if (options[:return_scores])
182
- results.map { |result| [ instantiate_mapreduce_result(result), result[:score] ] }.find_all { |result| ! result[0].nil? }
183
- else
184
- results.map { |result| instantiate_mapreduce_result(result) }.compact
185
- end
186
- end
187
-
188
- def all_ngrams(str, config, bound_number_returned = true)
189
- return {} if str.nil?
190
-
191
- if config[:remove_accents]
192
- if defined?(UnicodeUtils)
193
- str = UnicodeUtils.nfkd(str)
194
- elsif defined?(DiacriticsFu)
195
- str = DiacriticsFu::escape(str)
196
- end
197
- end
198
-
199
- # Remove any characters that aren't in the alphabet and aren't word separators
200
- filtered_str = str.mb_chars.downcase.to_s.split('').find_all{ |ch| config[:alphabet][ch] or config[:word_separators][ch] }.join('')
201
-
202
- # Figure out how many ngrams to extract from the string. If we can't afford to extract all ngrams,
203
- # step over the string in evenly spaced strides to extract ngrams. For example, to extract 3 3-letter
204
- # ngrams from 'abcdefghijk', we'd want to extract 'abc', 'efg', and 'ijk'.
205
- if bound_number_returned
206
- step_size = [((filtered_str.length - config[:ngram_width]).to_f / config[:max_ngrams_to_search]).ceil, 1].max
207
- else
208
- step_size = 1
209
- end
210
-
211
- # Create an array of records of the form {:ngram => x, :score => y} for all ngrams that occur in the
212
- # input string using the step size that we just computed. Let score(x,y) be the score of string x
213
- # compared with string y - assigning scores to ngrams with the square root-based scoring function
214
- # below and multiplying scores of matching ngrams together yields a score function that has the
215
- # property that score(x,y) > score(x,z) for any string z containing y and score(x,y) > score(x,z)
216
- # for any string z contained in y.
217
- ngram_array = (0..filtered_str.length - config[:ngram_width]).step(step_size).map do |i|
218
- if i == 0 or (config[:apply_prefix_scoring_to_all_words] and \
219
- config[:word_separators].has_key?(filtered_str[i-1].chr))
220
- score = Math.sqrt(1 + 1.0/filtered_str.length)
221
- else
222
- score = Math.sqrt(2.0/filtered_str.length)
223
- end
224
- {:ngram => filtered_str[i..i+config[:ngram_width]-1], :score => score}
225
- end
226
-
227
- # If an ngram appears multiple times in the query string, keep the max score
228
- ngram_array = ngram_array.group_by{ |h| h[:ngram] }.map{ |key, values| {:ngram => key, :score => values.map{ |v| v[:score] }.max} }
229
-
230
- if config[:index_short_prefixes] or config[:index_full_words]
231
- split_regex_def = config[:word_separators].keys.map{ |k| Regexp.escape(k) }.join
232
- split_regex = Regexp.compile("[#{split_regex_def}]")
233
- all_words = filtered_str.split(split_regex)
234
- end
235
-
236
- # Add 'short prefix' records to the array: prefixes of the string that are length (ngram_width - 1)
237
- if config[:index_short_prefixes]
238
- prefixes_seen = {}
239
- all_words.each do |word|
240
- next if word.length < config[:ngram_width]-1
241
- prefix = word[0...config[:ngram_width]-1]
242
- if prefixes_seen[prefix].nil? and (config[:stop_words][word].nil? or word == filtered_str)
243
- ngram_array << {:ngram => prefix, :score => 1 + 1.0/filtered_str.length}
244
- prefixes_seen[prefix] = true
245
- end
246
- end
247
- end
248
-
249
- # Add records to the array of ngrams for each full word in the string that isn't a stop word
250
- if config[:index_full_words]
251
- full_words_seen = {}
252
- all_words.each do |word|
253
- if word.length > 1 and full_words_seen[word].nil? and (config[:stop_words][word].nil? or word == filtered_str)
254
- ngram_array << {:ngram => word, :score => 1 + 1.0/filtered_str.length}
255
- full_words_seen[word] = true
256
- end
257
- end
258
- end
259
-
260
- # If an ngram appears as any combination of full word, short prefix, and ngram, keep the sum of the two scores
261
- Hash[ngram_array.group_by{ |h| h[:ngram] }.map{ |key, values| [key, values.map{ |v| v[:score] }.sum] }]
262
- end
263
-
264
- def remove_from_ngram_index
265
- self.mongoid_fulltext_config.each_pair do |index_name, fulltext_config|
266
- coll = collection.database[index_name]
267
- coll.find({'class' => self.name}).remove_all
268
- end
269
- end
270
-
271
- def update_ngram_index
272
- self.all.each do |model|
273
- model.update_ngram_index
274
- end
275
- end
276
-
277
- private
278
- # Take a list of filters to be mapped so they can update the query
279
- # used upon the fulltext search of the ngrams
280
- def map_query_filters filters
281
- Hash[filters.map {|key,value|
282
- case value
283
- when Hash then
284
- if value.has_key? :any then format_query_filter('$in',key,value[:any])
285
- elsif value.has_key? :all then format_query_filter('$all',key,value[:all])
286
- else raise UnknownFilterQueryOperator, value.keys.join(","), caller end
287
- else format_query_filter('$all',key,value)
288
- end
289
- }]
290
- end
291
- def format_query_filter operator, key, value
292
- ['filter_values.%s' % key, {operator => [value].flatten}]
293
- end
294
- end
295
-
296
- def update_ngram_index
297
- self.mongoid_fulltext_config.each_pair do |index_name, fulltext_config|
298
- if condition = fulltext_config[:update_if]
299
- case condition
300
- when Symbol; next unless self.send condition
301
- when String; next unless instance_eval condition
302
- when Proc; next unless condition.call self
303
- else; next
304
- end
305
- end
306
-
307
- # remove existing ngrams from external index
308
- coll = collection.database[index_name.to_sym]
309
- coll.find({'document_id' => self._id}).remove_all
310
- # extract ngrams from fields
311
- field_values = fulltext_config[:ngram_fields].map { |field| self.send(field) }
312
- ngrams = field_values.inject({}) { |accum, item| accum.update(self.class.all_ngrams(item, fulltext_config, false))}
313
- return if ngrams.empty?
314
- # apply filters, if necessary
315
- filter_values = nil
316
- if fulltext_config.has_key?(:filters)
317
- filter_values = Hash[fulltext_config[:filters].map do |key,value|
318
- begin
319
- [key, value.call(self)]
320
- rescue
321
- # Suppress any exceptions caused by filters
322
- end
323
- end.compact]
324
- end
325
- # insert new ngrams in external index
326
- ngrams.each_pair do |ngram, score|
327
- index_document = {'ngram' => ngram, 'document_id' => self._id, 'score' => score, 'class' => self.class.name}
328
- index_document['filter_values'] = filter_values if fulltext_config.has_key?(:filters)
329
- coll.insert(index_document)
330
- end
331
- end
332
- end
333
-
334
- def remove_from_ngram_index
335
- self.mongoid_fulltext_config.each_pair do |index_name, fulltext_config|
336
- coll = collection.database[index_name]
337
- coll.find({'document_id' => self._id}).remove_all
338
- end
339
- end
340
-
341
- end
1
+ require 'mongoid/full_text_search'
@@ -1,85 +1,19 @@
1
- # Generated by jeweler
2
- # DO NOT EDIT THIS FILE DIRECTLY
3
- # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
- # -*- encoding: utf-8 -*-
1
+ $LOAD_PATH.push File.expand_path('../lib', __FILE__)
2
+ require 'mongoid/full_text_search/version'
5
3
 
6
4
  Gem::Specification.new do |s|
7
- s.name = "mongoid_fulltext"
8
- s.version = "0.6.1"
9
-
10
- s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
- s.authors = ["Aaron Windsor"]
12
- s.date = "2013-04-03"
13
- s.description = "Full-text search for the Mongoid ORM, using n-grams extracted from text"
14
- s.email = "aaron.windsor@gmail.com"
15
- s.extra_rdoc_files = [
16
- "LICENSE",
17
- "README.md"
18
- ]
19
- s.files = [
20
- ".document",
21
- ".rspec",
22
- ".travis.yml",
23
- "CHANGELOG.md",
24
- "Gemfile",
25
- "LICENSE",
26
- "README.md",
27
- "Rakefile",
28
- "VERSION",
29
- "lib/mongoid_fulltext.rb",
30
- "lib/mongoid_indexes.rb",
31
- "mongoid_fulltext.gemspec",
32
- "spec/config/mongoid.yml",
33
- "spec/models/accentless_artwork.rb",
34
- "spec/models/advanced_artwork.rb",
35
- "spec/models/basic_artwork.rb",
36
- "spec/models/delayed_artwork.rb",
37
- "spec/models/external_artist.rb",
38
- "spec/models/external_artwork.rb",
39
- "spec/models/external_artwork_no_fields_supplied.rb",
40
- "spec/models/filtered_artist.rb",
41
- "spec/models/filtered_artwork.rb",
42
- "spec/models/filtered_other.rb",
43
- "spec/models/gallery/basic_artwork.rb",
44
- "spec/models/hidden_dragon.rb",
45
- "spec/models/multi_external_artwork.rb",
46
- "spec/models/multi_field_artist.rb",
47
- "spec/models/multi_field_artwork.rb",
48
- "spec/models/partitioned_artist.rb",
49
- "spec/models/russian_artwork.rb",
50
- "spec/models/short_prefixes_artwork.rb",
51
- "spec/models/stopwords_artwork.rb",
52
- "spec/mongoid/fulltext_spec.rb",
53
- "spec/spec_helper.rb"
54
- ]
55
- s.homepage = "http://github.com/aaw/mongoid_fulltext"
56
- s.licenses = ["MIT"]
57
- s.require_paths = ["lib"]
58
- s.rubygems_version = "1.8.25"
59
- s.summary = "Full-text search for the Mongoid ORM"
60
-
61
- if s.respond_to? :specification_version then
62
- s.specification_version = 3
63
-
64
- if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
65
- s.add_runtime_dependency(%q<mongoid>, ["~> 3.0"])
66
- s.add_runtime_dependency(%q<unicode_utils>, ["~> 1.0.0"])
67
- s.add_development_dependency(%q<bundler>, [">= 0"])
68
- s.add_development_dependency(%q<rspec>, ["~> 2.10.0"])
69
- s.add_development_dependency(%q<jeweler>, ["~> 1.8.3"])
70
- else
71
- s.add_dependency(%q<mongoid>, ["~> 3.0"])
72
- s.add_dependency(%q<unicode_utils>, ["~> 1.0.0"])
73
- s.add_dependency(%q<bundler>, [">= 0"])
74
- s.add_dependency(%q<rspec>, ["~> 2.10.0"])
75
- s.add_dependency(%q<jeweler>, ["~> 1.8.3"])
76
- end
77
- else
78
- s.add_dependency(%q<mongoid>, ["~> 3.0"])
79
- s.add_dependency(%q<unicode_utils>, ["~> 1.0.0"])
80
- s.add_dependency(%q<bundler>, [">= 0"])
81
- s.add_dependency(%q<rspec>, ["~> 2.10.0"])
82
- s.add_dependency(%q<jeweler>, ["~> 1.8.3"])
83
- end
5
+ s.name = 'mongoid_fulltext'
6
+ s.version = Mongoid::FullTextSearch::VERSION
7
+ s.authors = ['Aaron Windsor']
8
+ s.email = 'aaron.windsor@gmail.com'
9
+ s.platform = Gem::Platform::RUBY
10
+ s.required_rubygems_version = '>= 1.3.6'
11
+ s.files = `git ls-files`.split("\n")
12
+ s.require_paths = ['lib']
13
+ s.homepage = 'https://github.com/artsy/mongoid_fulltext'
14
+ s.licenses = ['MIT']
15
+ s.summary = 'Full-text search for the Mongoid ORM, using n-grams extracted from text.'
16
+ s.add_dependency 'mongoid', '>= 3.0'
17
+ s.add_dependency 'mongoid-compatibility'
18
+ s.add_dependency 'unicode_utils'
84
19
  end
85
-