activesearch 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: c0e0ed236897226e3b2101e34ebed32ca97248f5
4
- data.tar.gz: d5b3cee180db19ef832b071c174701a7a039741e
3
+ metadata.gz: b24050eefed5b21eb1b0d085fa964effdc06c2d8
4
+ data.tar.gz: 780df5ddeb72407c972869ab11a9a6e015018554
5
5
  SHA512:
6
- metadata.gz: 41303c2b0d475fc75f348d889e21b57d3c3add61d254fa947b9e137535a8ca65efe63379bd56c135ccba8749b894fd0ebcecd73f9cbe5c6f483c7f5efd9751e8
7
- data.tar.gz: 6e638fb66e68d4521299cb10a13f8b740b50888f8f2fbf4c9f800f11758b45a578bc5efba7af1faeb29731acb696dc6d13c8babb9c7e904b40601f186c65e2b9
6
+ metadata.gz: c1e5576e03f3f0ae6a610ddef63bf3c661e68520ef8fdae55ca4e98c9676f14fb038de8088b416c59b085515ea4568aa5b58500085003bae87ee80c375e66cc7
7
+ data.tar.gz: f1caac8c129064500ec93a3f13f7854ab83f698ed7e27f8729ae9fc21cf2a2a999a23fd75663304590bb1490ef0e979e2f3a2fff4ff7abd71b4aa884eb7e23c1
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --colour
2
+ --backtrace
data/Rakefile CHANGED
@@ -1 +1,19 @@
1
- require "bundler/gem_tasks"
1
+ #!/usr/bin/env rake
2
+ # encoding: utf-8
3
+
4
+ require 'rubygems'
5
+ require 'bundler/setup'
6
+
7
+ require 'rake'
8
+ require 'rspec'
9
+ require 'rspec/core/rake_task'
10
+ require 'rubygems/package_task'
11
+
12
+ # === Gems install tasks ===
13
+ Bundler::GemHelper.install_tasks
14
+
15
+ RSpec::Core::RakeTask.new('spec') do |spec|
16
+ spec.pattern = 'spec/**/*_spec.rb'
17
+ end
18
+
19
+ task :default => :spec
data/activesearch.gemspec CHANGED
@@ -6,8 +6,8 @@ require 'activesearch/version'
6
6
  Gem::Specification.new do |gem|
7
7
  gem.name = "activesearch"
8
8
  gem.version = ActiveSearch::VERSION
9
- gem.authors = ["Rodrigo Alvarez"]
10
- gem.email = ["papipo@gmail.com"]
9
+ gem.authors = ['Rodrigo Alvarez', 'Didier Lafforgue']
10
+ gem.email = ['papipo@gmail.com', 'didier.lafforgue@gmail.com']
11
11
  gem.description = %q{ORM agnostic full text search}
12
12
  gem.summary = %q{ActiveSearch lets you plug in a ruby module in any class that will allow you to do full text searches.}
13
13
  gem.homepage = ""
@@ -16,15 +16,16 @@ Gem::Specification.new do |gem|
16
16
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
17
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
18
  gem.require_paths = ["lib"]
19
-
19
+
20
20
  gem.add_dependency "activesupport"
21
21
  gem.add_dependency "sucker_punch"
22
22
  gem.add_dependency "actionpack"
23
-
23
+
24
24
  gem.add_development_dependency "rspec"
25
25
  gem.add_development_dependency "rspec-mocks"
26
26
  gem.add_development_dependency "active_attr"
27
27
  gem.add_development_dependency "mongoid", "~> 3"
28
+ gem.add_development_dependency "database_cleaner", "~> 0.9.1"
28
29
  gem.add_development_dependency "tire"
29
30
  gem.add_development_dependency "parallel_tests"
30
31
  gem.add_development_dependency "httparty"
@@ -2,7 +2,7 @@ require "sucker_punch"
2
2
 
3
3
  class ActiveSearch::Algolia::Worker
4
4
  include SuckerPunch::Job
5
-
5
+
6
6
  def perform(msg)
7
7
  begin
8
8
  case msg[:task]
@@ -10,7 +10,7 @@ class ActiveSearch::Algolia::Worker
10
10
  ::ActiveSearch::Algolia::Client.new.save(msg[:id], msg[:doc])
11
11
  when :deindex
12
12
  client = ::ActiveSearch::Algolia::Client.new
13
- client.query("", tags: "original_id:#{msg[:id]}")["hits"].each do |hit|
13
+ client.query("", tags: "original_type:#{msg[:type]},original_id:#{msg[:id]}")["hits"].each do |hit|
14
14
  client.delete(hit["objectID"])
15
15
  end
16
16
  end
@@ -4,13 +4,21 @@ require "activesearch/base"
4
4
  require "activesearch/proxy"
5
5
 
6
6
  module ActiveSearch
7
+
7
8
  def self.search(text, conditions = {}, options = {})
9
+ locale = options[:locale] || I18n.locale
10
+ conditions[:locale] ||= locale
11
+
8
12
  Proxy.new(text, conditions, options) do |text, conditions|
9
13
  Algolia::Client.new.query(text, tags: conditions_to_tags(conditions))["hits"].map! do |hit|
10
14
  if hit["_tags"]
11
15
  hit["_tags"].each do |tag|
12
- k, v = tag.split(':')
13
- hit[k] = v
16
+ # preserve other ":" characters
17
+ _segments = tag.split(':')
18
+
19
+ unless _segments.empty? || _segments[1..-1].empty?
20
+ hit[_segments.first] = _segments[1..-1].join(':')
21
+ end
14
22
  end
15
23
  hit.delete("_tags")
16
24
  end
@@ -20,8 +28,9 @@ module ActiveSearch
20
28
  end
21
29
 
22
30
  protected
31
+
23
32
  def self.conditions_to_tags(conditions)
24
- conditions.merge(locale: I18n.locale).map { |c| c.join(':') }.join(',')
33
+ conditions.map { |c| c.join(':') }.join(',')
25
34
  end
26
35
 
27
36
  module Algolia
@@ -33,32 +42,35 @@ module ActiveSearch
33
42
 
34
43
  protected
35
44
  def reindex
36
- Worker.new.async.perform(task: :reindex, id: "#{indexable_id}_#{I18n.locale}", doc: to_indexable)
45
+ Worker.new.async.perform(task: :reindex, id: "#{indexable_id}_#{search_locale}", doc: to_indexable)
37
46
  end
38
47
 
39
48
  def deindex
40
- Worker.new.async.perform(task: :deindex, id: indexable_id)
49
+ Worker.new.async.perform(task: :deindex, id: self.id, type: self.class.to_s)
41
50
  end
42
51
 
43
52
  def to_indexable
44
- doc = {}
45
- search_fields.each do |field|
46
- if send(field)
47
- doc[field.to_s] = if send(field).is_a?(Hash) && send(field).has_key?(I18n.locale.to_s)
48
- ActiveSearch.strip_tags(send(field)[I18n.locale.to_s])
49
- else
50
- ActiveSearch.strip_tags(send(field))
53
+ {}.tap do |doc|
54
+ _locale = search_locale
55
+
56
+ search_fields.each do |field|
57
+ if content = send(field)
58
+ doc[field.to_s] = if content.is_a?(Hash) && content.has_key?(_locale)
59
+ ActiveSearch.strip_tags(content[_locale])
60
+ else
61
+ ActiveSearch.strip_tags(content)
62
+ end
51
63
  end
52
64
  end
53
- end
54
65
 
55
- (Array(search_options[:store]) - search_fields).each do |field|
56
- doc["_tags"] ||= []
57
- doc["_tags"] << "#{field}:#{self.send(field)}"
66
+ (Array(search_options[:store]) - search_fields).each do |field|
67
+ doc["_tags"] ||= []
68
+ doc["_tags"] << "#{field}:#{self.send(field)}"
69
+ end
70
+ doc["_tags"] << "locale:#{_locale}"
71
+ doc["_tags"] << "original_type:#{self.class.to_s}"
72
+ doc["_tags"] << "original_id:#{self.id}"
58
73
  end
59
- doc["_tags"] << "locale:#{I18n.locale}"
60
- doc["_tags"] << "original_id:#{indexable_id}"
61
- doc
62
74
  end
63
75
  end
64
76
  end
@@ -7,25 +7,37 @@ module ActiveSearch
7
7
  value.gsub(/<\/?[^>]*>/, '')
8
8
  when Hash
9
9
  value.each_with_object({}) { |(k,v),h| h[k] = strip_tags(v) }
10
+ when Array
11
+ value.map { |v| strip_tags(v) }
10
12
  else
11
13
  value
12
14
  end
13
15
  end
14
-
16
+
15
17
  module Base
16
18
  def self.included(parent)
17
19
  parent.extend ClassMethods
18
20
  parent.class_attribute :search_parameters, instance_reader: false
19
21
  end
20
-
22
+
21
23
  def search_options
22
24
  search_parameters.last.is_a?(Hash) ? search_parameters.last : {}
23
25
  end
24
-
26
+
25
27
  def search_fields
26
28
  search_parameters.last.is_a?(Hash) ? search_parameters[0...-1] : search_parameters
27
29
  end
28
-
30
+
31
+ def search_locale
32
+ search_locale = search_options[:locale] || I18n.locale.to_s
33
+
34
+ if search_locale.respond_to?(:call)
35
+ search_locale.call
36
+ else
37
+ search_locale
38
+ end
39
+ end
40
+
29
41
  def search_parameters
30
42
  if self.class.search_parameters.is_a?(Symbol)
31
43
  self.send(self.class.search_parameters)
@@ -33,14 +45,14 @@ module ActiveSearch
33
45
  self.class.search_parameters
34
46
  end
35
47
  end
36
-
48
+
37
49
  module ClassMethods
38
50
  def search_by(params, conditions = {})
39
51
  after_save :reindex, conditions
40
52
  after_destroy :deindex, conditions
41
53
  self.search_parameters = params
42
54
  end
43
-
55
+
44
56
  end
45
57
  end
46
58
  end
@@ -0,0 +1,115 @@
1
+ module ActiveSearch
2
+ module Mongoid
3
+ class Index
4
+
5
+ include ::Mongoid::Document
6
+
7
+ ## fields ##
8
+ field :original_type, type: String
9
+ field :original_id, type: Moped::BSON::ObjectId
10
+ field :language, type: String
11
+ field :locale, type: String
12
+ field :content, type: Array
13
+ field :stored, type: Hash, default: {}
14
+ alias_method :to_hash, :stored
15
+
16
+ ## indexes ##
17
+ index({ content: 'text', locale: 1 })
18
+ index({ original_type: 1, original_id: 1, locale: 1 }, unique: true)
19
+
20
+ ## methods ##
21
+
22
+ def store_language(original)
23
+ self.locale = original.search_locale
24
+ self.language = self.class.locale_to_language(self.locale)
25
+ end
26
+
27
+ def store_fields(original)
28
+ self.stored = {}
29
+
30
+ fields = (original.search_fields + (original.search_options[:store] || [])).uniq
31
+
32
+ fields.each do |f|
33
+ if original.send(f).present?
34
+ self.stored[f] = ActiveSearch.strip_tags(original.send(f))
35
+ end
36
+ end
37
+ end
38
+
39
+ def refresh_content(original)
40
+ self.content = original.to_indexable.values.flatten
41
+ end
42
+
43
+ ## class methods ##
44
+
45
+ def self.search(query, conditions = {})
46
+ language = self.locale_to_language(conditions[:locale] || I18n.locale)
47
+
48
+ filter = {}
49
+ conditions.each do |key, value|
50
+ if key == :locale
51
+ filter['locale'] = value.to_s
52
+ else
53
+ filter["stored.#{key}"] = value
54
+ end
55
+ end
56
+
57
+ session = ::Mongoid.session('default')
58
+ results = session.command({
59
+ 'text' => collection.name,
60
+ 'search' => query,
61
+ 'language' => language,
62
+ 'filter' => filter
63
+ })
64
+ if results.has_key?('results')
65
+ results['results'].map do |result|
66
+ result['obj']['stored'].merge(result['obj'].slice('locale', 'original_type', 'original_id'))
67
+ end
68
+ else
69
+ []
70
+ end
71
+ end
72
+
73
+ def self.deindex(original)
74
+ # delete the records in all the locales
75
+ self.where(original_type: original.class.to_s, original_id: original.id).destroy
76
+ end
77
+
78
+ def self.reindex(original, fields, options)
79
+ # re-index only in the current locale (unless another locale has been specified)
80
+ locale = original.search_locale
81
+
82
+ # find the exact index scoped by the locale or build a new one
83
+ doc = find_or_initialize_by(original_type: original.class.to_s, original_id: original.id, locale: locale)
84
+
85
+ doc.store_language(original)
86
+ doc.store_fields(original) #, fields, options)
87
+ doc.refresh_content(original)
88
+
89
+ # save it (create or update it)
90
+ doc.save
91
+ end
92
+
93
+ def self.locale_to_language(locale)
94
+ {
95
+ dk: 'danish',
96
+ nl: 'dutch',
97
+ en: 'english',
98
+ fi: 'finnish',
99
+ fr: 'french',
100
+ de: 'german',
101
+ hu: 'hungarian',
102
+ it: 'italian',
103
+ nb: 'norwegian',
104
+ br: 'portuguese',
105
+ pt: 'portuguese',
106
+ ro: 'romanian',
107
+ ru: 'russian',
108
+ es: 'spanish',
109
+ se: 'swedish',
110
+ tr: 'turkish'
111
+ }[locale.to_sym] || 'english'
112
+ end
113
+ end
114
+ end
115
+ end
@@ -1,15 +1,15 @@
1
1
  require 'activesearch/base'
2
2
  require 'activesearch/proxy'
3
- require 'activesearch/mongoid/model'
3
+ require 'activesearch/mongoid/index'
4
4
 
5
5
  module ActiveSearch
6
6
 
7
7
  def self.search(text, conditions = {}, options = {})
8
+ locale = options[:locale] || I18n.locale
9
+ conditions[:locale] ||= locale
10
+
8
11
  Proxy.new(text, conditions, options) do |text, conditions|
9
- text = text.downcase.split(/\s+/)
10
- conditions.keys.each { |k| conditions["_stored.#{k}"] = conditions.delete(k) }
11
- conditions.merge!(:_keywords.in => text + text.map { |word| "#{I18n.locale}:#{word}"})
12
- Mongoid::Model.where(conditions)
12
+ ActiveSearch::Mongoid::Index.search(text, conditions)
13
13
  end
14
14
  end
15
15
 
@@ -20,13 +20,30 @@ module ActiveSearch
20
20
  end
21
21
  end
22
22
 
23
+ def to_indexable
24
+ {}.tap do |doc|
25
+ _locale = search_locale
26
+
27
+ search_fields.each do |field|
28
+ if content = send(field)
29
+ doc[field.to_s] = if content.is_a?(Hash) && content.has_key?(_locale)
30
+ ActiveSearch.strip_tags(content[_locale])
31
+ else
32
+ ActiveSearch.strip_tags(content)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+
23
39
  protected
40
+
24
41
  def reindex
25
- ActiveSearch::Mongoid::Model.reindex(self, self.search_fields, self.search_options)
42
+ ActiveSearch::Mongoid::Index.reindex(self, self.search_fields, self.search_options)
26
43
  end
27
44
 
28
45
  def deindex
29
- ActiveSearch::Mongoid::Model.deindex(self)
46
+ ActiveSearch::Mongoid::Index.deindex(self)
30
47
  end
31
48
  end
32
49
  end
@@ -1,5 +1,6 @@
1
1
  require 'action_view'
2
2
  require 'active_support/core_ext'
3
+ require 'active_support/core_ext/hash/slice'
3
4
 
4
5
  module ActiveSearch
5
6
  class Result < Hash
@@ -9,16 +10,22 @@ module ActiveSearch
9
10
  include ActionView::Helpers::TextHelper
10
11
 
11
12
  def initialize(result, text, options = {})
13
+ locale = (options[:locale] || I18n.locale).to_s
14
+
12
15
  @text = text
13
16
  result.to_hash.each do |k,v|
14
- unless v.nil? || k.to_s.start_with?('_')
15
- self[k.to_s] = v.respond_to?(:has_key?) && v.has_key?(I18n.locale.to_s) ? v[I18n.locale.to_s] : v
17
+ unless v.nil?
18
+ self[k.to_s] = v.respond_to?(:has_key?) && v.has_key?(locale) ? v[locale] : v
16
19
  end
17
20
  end
18
21
 
19
22
  self.build_highlighted_fields(options[:radius])
20
23
  end
21
24
 
25
+ def slice(*keys)
26
+ ::Hash.new.update(self).slice(*keys)
27
+ end
28
+
22
29
  protected
23
30
 
24
31
  def build_highlighted_fields(radius = nil)
@@ -1,3 +1,3 @@
1
1
  module ActiveSearch
2
- VERSION = "0.2.0"
2
+ VERSION = '0.3.0'
3
3
  end
@@ -0,0 +1,32 @@
1
+ #encoding: utf-8
2
+ require 'spec_helper'
3
+
4
+ describe 'ActiveSearch::Algolia' do
5
+
6
+ before(:all) do
7
+ SetupEngine.setup(:algolia)
8
+
9
+ # can not use the rspec let method
10
+ @model = AlgoliaModel
11
+ @another_model = AnotherAlgoliaModel
12
+ end
13
+
14
+ include_examples 'an engine'
15
+
16
+ context 'retry on errors' do
17
+
18
+ before do
19
+ times_called = 0
20
+ @instance = AlgoliaModel.new(title: 'Example')
21
+ ActiveSearch::Algolia::Client.should_receive(:put).exactly(3).times.and_return do
22
+ times_called += 1
23
+ raise Errno::ECONNRESET if times_called <= 2
24
+ end
25
+ end
26
+
27
+ subject { -> { @instance.save } }
28
+
29
+ it { should_not raise_error }
30
+ end
31
+
32
+ end
@@ -1,64 +1,64 @@
1
- require 'activesearch/base'
1
+ require 'spec_helper'
2
2
 
3
3
  describe ActiveSearch::Base do
4
4
  before do
5
5
  @klass = Class.new do
6
6
  include ActiveSearch::Base
7
-
7
+
8
8
  def self.after_save(*args); end
9
9
  def self.after_destroy(*args); end
10
-
10
+
11
11
  end
12
12
  end
13
-
13
+
14
14
  context "search_by" do
15
15
  let(:call_search_by) do
16
16
  @klass.class_eval do
17
17
  search_by [:field], if: :something_happens, unless: :its_friday
18
18
  end
19
19
  end
20
-
20
+
21
21
  it "should rely on after_save and after_destroy callbacks passing conditions" do
22
22
  @klass.should_receive(:after_save).with(:reindex, if: :something_happens, unless: :its_friday)
23
23
  @klass.should_receive(:after_destroy).with(:deindex, if: :something_happens, unless: :its_friday)
24
24
  call_search_by
25
25
  end
26
-
26
+
27
27
  it "should store the parameters in search_parameters" do
28
28
  call_search_by
29
29
  @klass.send(:search_parameters).should == [:field]
30
30
  end
31
31
  end
32
-
32
+
33
33
  context "utility methods with options" do
34
34
  before do
35
35
  @klass.stub(:search_parameters).and_return([:field, store: [:another_field]])
36
36
  end
37
-
37
+
38
38
  it "search_options should return the hash at the end of the parameters" do
39
39
  @klass.new.search_options.should == {store: [:another_field]}
40
40
  end
41
-
41
+
42
42
  it "search_fields should return all parameters except the options" do
43
43
  @klass.new.search_fields.should == [:field]
44
44
  end
45
45
  end
46
-
46
+
47
47
  context "search_by with virtual parameters" do
48
48
  before do
49
49
  @klass.class_eval do
50
50
  def options_for_search
51
51
  [@field]
52
52
  end
53
-
53
+
54
54
  def initialize(field)
55
55
  @field = field
56
56
  end
57
-
57
+
58
58
  search_by :options_for_search
59
59
  end
60
60
  end
61
-
61
+
62
62
  it "should work" do
63
63
  @klass.new(:first).send(:search_parameters).should == [:first]
64
64
  @klass.new(:second).send(:search_parameters).should == [:second]
@@ -0,0 +1,56 @@
1
+ #encoding: utf-8
2
+ require 'spec_helper'
3
+
4
+ describe 'ActiveSearch::Mongoid' do
5
+
6
+ before(:all) do
7
+ SetupEngine.setup(:mongoid)
8
+
9
+ # can not use the rspec let method
10
+ @model = MongoidModel
11
+ @another_model = AnotherMongoidModel
12
+ end
13
+
14
+ include_examples 'an engine'
15
+
16
+ describe 'localized content' do
17
+
18
+ before(:all) do
19
+ @localized = LocalizedMongoidModel.create!(title: "<strong>English</strong> English")
20
+ I18n.with_locale(:es) do
21
+ @localized.title = "Español Español"
22
+ @localized.save!
23
+ end
24
+ end
25
+
26
+ it "should be able to find by different locales" do
27
+ ActiveSearch.search("english").first["title"].should == "English English"
28
+ I18n.with_locale(:es) do
29
+ ActiveSearch.search("español").first["title"].should == "Español Español"
30
+ end
31
+ end
32
+
33
+ it "finds by a different locale" do
34
+ ActiveSearch.search("español", {}, { locale: 'es'}).first["title"].should == "Español Español"
35
+ end
36
+
37
+ it "should store content with tags stripped" do
38
+ index = ActiveSearch::Mongoid::Index.where(original_type: "LocalizedMongoidModel", original_id: @localized.id, locale: 'en')
39
+ index.first.content.should == ["English English"]
40
+ end
41
+
42
+ it "handles empty translations" do
43
+ lambda { LocalizedMongoidModel.create!(title: nil, not_localized: "example") }.should_not raise_error
44
+ end
45
+
46
+ it "handles empty fields" do
47
+ lambda { LocalizedMongoidModel.create!(title: "Example", not_localized: nil) }.should_not raise_error
48
+ end
49
+
50
+ it "handles nil values in arrays" do
51
+ lambda { LocalizedMongoidModel.create!(title: "Example", not_localized: "example", array: [nil]) }.should_not raise_error
52
+ end
53
+
54
+ end
55
+
56
+ end
@@ -1,8 +1,6 @@
1
- require 'activesearch/algolia'
2
-
3
1
  class AlgoliaModel < ActiveMimic
4
2
  include ActiveSearch::Algolia
5
-
3
+
6
4
  attribute :title
7
5
  attribute :text
8
6
  attribute :junk
@@ -10,19 +8,19 @@ class AlgoliaModel < ActiveMimic
10
8
  attribute :scope_id, type: Integer
11
9
  attribute :tags, type: Array
12
10
  localized_attribute :color
13
-
11
+
14
12
  search_by [:title, :text, :tags, :color, store: [:title, :junk, :scope_id]], if: lambda { !self.special }
15
13
 
16
14
  end
17
15
 
18
16
  class AnotherAlgoliaModel < ActiveMimic
19
17
  include ActiveSearch::Algolia
20
-
18
+
21
19
  attribute :title, type: String
22
20
  attribute :scope_id, type: Integer
23
21
  localized_attribute :color
24
22
  search_by [:title, store: [:title, :virtual, :scope_id, :color]]
25
-
23
+
26
24
  def virtual
27
25
  "virtual"
28
26
  end
@@ -1,11 +1,9 @@
1
- require 'activesearch/elastic_search'
2
-
3
1
  module ElasticSearchRefresh
4
-
2
+
5
3
  def save
6
4
  super.tap { Tire.index('_all') { refresh }}
7
5
  end
8
-
6
+
9
7
  def destroy
10
8
  super.tap { Tire.index('_all') { refresh }}
11
9
  end
@@ -14,13 +12,12 @@ end
14
12
  class ElasticSearchModel < ActiveMimic
15
13
  include ActiveSearch::ElasticSearch
16
14
  include ElasticSearchRefresh
17
-
15
+
18
16
  attribute :title
19
- attribute :text
20
17
  attribute :junk
21
18
  attribute :special, default: false
22
19
  attribute :tags, type: Array
23
-
20
+
24
21
  search_by [:title, :text, :tags, store: [:title, :junk]], if: lambda { !self.special }
25
22
 
26
23
  end
@@ -28,10 +25,10 @@ end
28
25
  class AnotherElasticSearchModel < ActiveMimic
29
26
  include ActiveSearch::ElasticSearch
30
27
  include ElasticSearchRefresh
31
-
28
+
32
29
  attribute :title, type: String
33
30
  search_by [:title, store: [:title, :virtual]]
34
-
31
+
35
32
  def virtual
36
33
  "virtual"
37
34
  end