tire 0.1.16 → 0.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.
- data/README.markdown +131 -62
- data/examples/rails-application-template.rb +1 -1
- data/examples/tire-dsl.rb +6 -6
- data/lib/tire/index.rb +7 -27
- data/lib/tire/model/callbacks.rb +1 -1
- data/lib/tire/model/search.rb +63 -27
- data/lib/tire/results/collection.rb +33 -15
- data/lib/tire/results/item.rb +46 -8
- data/lib/tire/search.rb +2 -5
- data/lib/tire/search/sort.rb +0 -7
- data/lib/tire/version.rb +6 -6
- data/test/integration/active_model_searchable_test.rb +26 -5
- data/test/integration/active_record_searchable_test.rb +94 -9
- data/test/integration/filters_test.rb +1 -1
- data/test/integration/highlight_test.rb +0 -1
- data/test/integration/index_mapping_test.rb +3 -4
- data/test/integration/percolator_test.rb +6 -6
- data/test/integration/persistent_model_test.rb +19 -1
- data/test/integration/query_string_test.rb +9 -0
- data/test/integration/results_test.rb +11 -0
- data/test/models/active_record_models.rb +49 -0
- data/test/test_helper.rb +2 -0
- data/test/unit/index_test.rb +16 -5
- data/test/unit/model_search_test.rb +45 -6
- data/test/unit/results_collection_test.rb +51 -2
- data/test/unit/results_item_test.rb +64 -1
- data/test/unit/search_sort_test.rb +26 -59
- data/test/unit/search_test.rb +17 -1
- metadata +16 -49
- data/test/models/active_record_article.rb +0 -12
data/lib/tire/model/callbacks.rb
CHANGED
@@ -9,7 +9,7 @@ module Tire
|
|
9
9
|
base.send :after_destroy, :update_elastic_search_index
|
10
10
|
end
|
11
11
|
|
12
|
-
if base.respond_to?(:before_destroy) && !base.instance_methods.include?(
|
12
|
+
if base.respond_to?(:before_destroy) && !base.instance_methods.map(&:to_sym).include?(:destroyed?)
|
13
13
|
base.class_eval do
|
14
14
|
before_destroy { @destroyed = true }
|
15
15
|
def destroyed?; !!@destroyed; end
|
data/lib/tire/model/search.rb
CHANGED
@@ -28,36 +28,67 @@ module Tire
|
|
28
28
|
self.serializable_hash
|
29
29
|
end unless instance_methods.map(&:to_sym).include?(:to_hash)
|
30
30
|
end
|
31
|
+
|
32
|
+
Results::Item.send :include, Loader
|
31
33
|
end
|
32
34
|
|
33
35
|
module ClassMethods
|
34
36
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
37
|
+
# Returns search results for a given query.
|
38
|
+
#
|
39
|
+
# Query can be passed simply as a String:
|
40
|
+
#
|
41
|
+
# Article.search 'love'
|
42
|
+
#
|
43
|
+
# Any options, such as pagination or sorting, can be passed as a second argument:
|
44
|
+
#
|
45
|
+
# Article.search 'love', :per_page => 25, :page => 2
|
46
|
+
# Article.search 'love', :sort => 'title'
|
47
|
+
#
|
48
|
+
# For more powerful query definition, use the query DSL passed as a block:
|
49
|
+
#
|
50
|
+
# Article.search do
|
51
|
+
# query { terms :tags, ['ruby', 'python'] }
|
52
|
+
# facet 'tags' { terms :tags }
|
53
|
+
# end
|
54
|
+
#
|
55
|
+
# You can pass options as the first argument, in this case:
|
56
|
+
#
|
57
|
+
# Article.search :per_page => 25, :page => 2 do
|
58
|
+
# query { string 'love' }
|
59
|
+
# end
|
60
|
+
#
|
61
|
+
#
|
62
|
+
def search(*args, &block)
|
63
|
+
default_options = {:type => document_type}
|
41
64
|
|
42
|
-
|
43
|
-
|
44
|
-
s.query { string query }
|
45
|
-
s.sort do
|
46
|
-
sort.each do |t|
|
47
|
-
field_name, direction = t.split(' ')
|
48
|
-
by field_name, direction
|
49
|
-
end
|
50
|
-
end unless sort.empty?
|
51
|
-
s.size( options[:per_page].to_i ) if options[:per_page]
|
52
|
-
s.from( options[:page].to_i <= 1 ? 0 : (options[:per_page].to_i * (options[:page].to_i-1)) ) if options[:page] && options[:per_page]
|
53
|
-
s.perform.results
|
65
|
+
if block_given?
|
66
|
+
options = args.shift || {}
|
54
67
|
else
|
55
|
-
|
68
|
+
query, options = args
|
69
|
+
options ||= {}
|
70
|
+
end
|
71
|
+
|
72
|
+
sort = Array( options[:order] || options[:sort] )
|
73
|
+
options = default_options.update(options)
|
74
|
+
|
75
|
+
s = Tire::Search::Search.new(elasticsearch_index.name, options)
|
76
|
+
s.size( options[:per_page].to_i ) if options[:per_page]
|
77
|
+
s.from( options[:page].to_i <= 1 ? 0 : (options[:per_page].to_i * (options[:page].to_i-1)) ) if options[:page] && options[:per_page]
|
78
|
+
s.sort do
|
79
|
+
sort.each do |t|
|
80
|
+
field_name, direction = t.split(' ')
|
81
|
+
by field_name, direction
|
82
|
+
end
|
83
|
+
end unless sort.empty?
|
84
|
+
|
85
|
+
if block_given?
|
56
86
|
block.arity < 1 ? s.instance_eval(&block) : block.call(s)
|
57
|
-
|
87
|
+
else
|
88
|
+
s.query { string query }
|
58
89
|
end
|
59
|
-
|
60
|
-
|
90
|
+
|
91
|
+
s.perform.results
|
61
92
|
end
|
62
93
|
|
63
94
|
# Wrapper for the ES index for this class
|
@@ -76,11 +107,6 @@ module Tire
|
|
76
107
|
|
77
108
|
module InstanceMethods
|
78
109
|
|
79
|
-
def score
|
80
|
-
Tire.warn "#{self.class}#score has been deprecated, please use #{self.class}#_score instead."
|
81
|
-
attributes['_score']
|
82
|
-
end
|
83
|
-
|
84
110
|
def index
|
85
111
|
self.class.elasticsearch_index
|
86
112
|
end
|
@@ -114,6 +140,16 @@ module Tire
|
|
114
140
|
|
115
141
|
end
|
116
142
|
|
143
|
+
module Loader
|
144
|
+
|
145
|
+
# Load the "real" model from the database via the corresponding model's `find` method
|
146
|
+
#
|
147
|
+
def load(options=nil)
|
148
|
+
options ? self.class.find(self.id, options) : self.class.find(self.id)
|
149
|
+
end
|
150
|
+
|
151
|
+
end
|
152
|
+
|
117
153
|
extend ClassMethods
|
118
154
|
end
|
119
155
|
|
@@ -18,26 +18,44 @@ module Tire
|
|
18
18
|
|
19
19
|
def results
|
20
20
|
@results ||= begin
|
21
|
-
@
|
22
|
-
|
23
|
-
|
24
|
-
|
21
|
+
unless @options[:load]
|
22
|
+
@response['hits']['hits'].map do |h|
|
23
|
+
if @wrapper == Hash then h
|
24
|
+
else
|
25
|
+
document = {}
|
25
26
|
|
26
|
-
|
27
|
-
|
28
|
-
|
27
|
+
# Update the document with content and ID
|
28
|
+
document = h['_source'] ? document.update( h['_source'] || {} ) : document.update( __parse_fields__(h['fields']) )
|
29
|
+
document.update( {'id' => h['_id']} )
|
29
30
|
|
30
|
-
|
31
|
-
|
31
|
+
# Update the document with meta information
|
32
|
+
['_score', '_type', '_index', '_version', 'sort', 'highlight'].each { |key| document.update( {key => h[key]} || {} ) }
|
32
33
|
|
33
|
-
|
34
|
-
if @wrapper.respond_to?(:instantiate, true)
|
35
|
-
@wrapper.send(:instantiate, document)
|
36
|
-
else
|
34
|
+
# Return an instance of the "wrapper" class
|
37
35
|
@wrapper.new(document)
|
38
36
|
end
|
39
|
-
|
40
|
-
|
37
|
+
end
|
38
|
+
else
|
39
|
+
begin
|
40
|
+
return [] if @response['hits']['total'] == 0
|
41
|
+
|
42
|
+
type = @response['hits']['hits'].first['_type']
|
43
|
+
raise NoMethodError, "You have tried to eager load the model instances, " +
|
44
|
+
"but Tire cannot find the model class because " +
|
45
|
+
"document has no _type property." unless type
|
46
|
+
|
47
|
+
klass = type.camelize.constantize
|
48
|
+
ids = @response['hits']['hits'].map { |h| h['_id'] }
|
49
|
+
records = @options[:load] === true ? klass.find(ids) : klass.find(ids, @options[:load])
|
50
|
+
|
51
|
+
# Reorder records to preserve order from search results
|
52
|
+
ids.map { |id| records.detect { |record| record.id.to_s == id.to_s } }
|
53
|
+
rescue NameError => e
|
54
|
+
raise NameError, "You have tried to eager load the model instances, but" +
|
55
|
+
"Tire cannot find the model class '#{type.camelize}' " +
|
56
|
+
"based on _type '#{type}'.", e.backtrace
|
57
|
+
end
|
58
|
+
end
|
41
59
|
end
|
42
60
|
end
|
43
61
|
|
data/lib/tire/results/item.rb
CHANGED
@@ -1,15 +1,22 @@
|
|
1
1
|
module Tire
|
2
2
|
module Results
|
3
3
|
|
4
|
-
class Item
|
4
|
+
class Item
|
5
|
+
extend ActiveModel::Naming
|
6
|
+
include ActiveModel::Conversion
|
5
7
|
|
6
8
|
# Create new instance, recursively converting all Hashes to Item
|
7
9
|
# and leaving everything else alone.
|
8
10
|
#
|
9
11
|
def initialize(args={})
|
10
12
|
raise ArgumentError, "Please pass a Hash-like object" unless args.respond_to?(:each_pair)
|
13
|
+
@attributes = {}
|
11
14
|
args.each_pair do |key, value|
|
12
|
-
|
15
|
+
if value.is_a?(Array)
|
16
|
+
@attributes[key.to_sym] = value.map { |item| @attributes[key.to_sym] = item.is_a?(Hash) ? self.class.new(item.to_hash) : item }
|
17
|
+
else
|
18
|
+
@attributes[key.to_sym] = value.is_a?(Hash) ? self.class.new(value.to_hash) : value
|
19
|
+
end
|
13
20
|
end
|
14
21
|
end
|
15
22
|
|
@@ -17,20 +24,51 @@ module Tire
|
|
17
24
|
# otherwise return +nil+.
|
18
25
|
#
|
19
26
|
def method_missing(method_name, *arguments)
|
20
|
-
|
27
|
+
@attributes.has_key?(method_name.to_sym) ? @attributes[method_name.to_sym] : nil
|
28
|
+
end
|
29
|
+
|
30
|
+
def [](key)
|
31
|
+
@attributes[key]
|
21
32
|
end
|
22
33
|
|
23
|
-
# Get ID
|
24
|
-
#
|
25
34
|
def id
|
26
|
-
|
35
|
+
@attributes[:_id] || @attributes[:id]
|
36
|
+
end
|
37
|
+
|
38
|
+
def persisted?
|
39
|
+
!!id
|
40
|
+
end
|
41
|
+
|
42
|
+
def errors
|
43
|
+
ActiveModel::Errors.new(self)
|
44
|
+
end
|
45
|
+
|
46
|
+
def valid?
|
47
|
+
true
|
48
|
+
end
|
49
|
+
|
50
|
+
def to_key
|
51
|
+
persisted? ? [id] : nil
|
52
|
+
end
|
53
|
+
|
54
|
+
def to_hash
|
55
|
+
@attributes
|
56
|
+
end
|
57
|
+
|
58
|
+
# Let's pretend we're someone else in Rails
|
59
|
+
#
|
60
|
+
def class
|
61
|
+
defined?(::Rails) && @attributes[:_type] ? @attributes[:_type].camelize.constantize : super
|
27
62
|
end
|
28
63
|
|
29
64
|
def inspect
|
30
|
-
s = [];
|
31
|
-
%Q|<Item #{s.join(', ')}>|
|
65
|
+
s = []; @attributes.each { |k,v| s << "#{k}: #{v.inspect}" }
|
66
|
+
%Q|<Item#{self.class.to_s == 'Tire::Results::Item' ? '' : " (#{self.class})"} #{s.join(', ')}>|
|
32
67
|
end
|
33
68
|
|
69
|
+
def to_json(options=nil)
|
70
|
+
@attributes.to_json(options)
|
71
|
+
end
|
34
72
|
alias_method :to_indexed_json, :to_json
|
35
73
|
|
36
74
|
end
|
data/lib/tire/search.rb
CHANGED
@@ -6,9 +6,6 @@ module Tire
|
|
6
6
|
attr_reader :indices, :url, :results, :response, :json, :query, :facets, :filters, :options
|
7
7
|
|
8
8
|
def initialize(indices=nil, options = {}, &block)
|
9
|
-
Tire.warn "Passing indices as multiple arguments to the `Search.new` method " +
|
10
|
-
"has been deprecated, please pass them as an Array: " +
|
11
|
-
"Search.new([#{indices}, #{options}])" if options.is_a?(String)
|
12
9
|
@indices = Array(indices)
|
13
10
|
@options = options
|
14
11
|
@type = @options[:type]
|
@@ -64,8 +61,8 @@ module Tire
|
|
64
61
|
self
|
65
62
|
end
|
66
63
|
|
67
|
-
def fields(fields
|
68
|
-
@fields = fields
|
64
|
+
def fields(*fields)
|
65
|
+
@fields = Array(fields.flatten)
|
69
66
|
self
|
70
67
|
end
|
71
68
|
|
data/lib/tire/search/sort.rb
CHANGED
@@ -12,13 +12,6 @@ module Tire
|
|
12
12
|
self
|
13
13
|
end
|
14
14
|
|
15
|
-
def method_missing(id, *args, &block)
|
16
|
-
Tire.warn "Using methods when sorting has been deprecated, please use the `by` method: " +
|
17
|
-
"sort { by :#{id}#{ args.empty? ? '' : ', ' + args.first.inspect } }"
|
18
|
-
|
19
|
-
by id, args.shift
|
20
|
-
end
|
21
|
-
|
22
15
|
def to_ary
|
23
16
|
@value
|
24
17
|
end
|
data/lib/tire/version.rb
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
module Tire
|
2
|
-
VERSION = "0.
|
2
|
+
VERSION = "0.2.0"
|
3
3
|
|
4
4
|
CHANGELOG =<<-END
|
5
5
|
IMPORTANT CHANGES LATELY:
|
6
6
|
|
7
|
-
#
|
8
|
-
#
|
9
|
-
#
|
10
|
-
#
|
11
|
-
#
|
7
|
+
# By default, results are wrapped in Item class (05a1331)
|
8
|
+
# Completely rewritten ActiveModel/ActiveRecord support
|
9
|
+
# Added method to items for loading the "real" model from database (f9273bc)
|
10
|
+
# Added the ':load' option to eagerly load results from database (1e34cde)
|
11
|
+
# Deprecated the dynamic sort methods, use the 'sort { by :field_name }' syntax
|
12
12
|
END
|
13
13
|
end
|
@@ -16,7 +16,7 @@ module Tire
|
|
16
16
|
SupermodelArticle.delete_all
|
17
17
|
end
|
18
18
|
|
19
|
-
context "ActiveModel" do
|
19
|
+
context "ActiveModel integration" do
|
20
20
|
|
21
21
|
setup do
|
22
22
|
Tire.index('supermodel_articles').delete
|
@@ -42,7 +42,6 @@ module Tire
|
|
42
42
|
end
|
43
43
|
|
44
44
|
a.index.refresh
|
45
|
-
sleep(1.5)
|
46
45
|
|
47
46
|
# The index should contain 2 documents
|
48
47
|
assert_equal 2, Tire.search('supermodel_articles') { query { all } }.results.size
|
@@ -52,7 +51,7 @@ module Tire
|
|
52
51
|
# The model should find only 1 document
|
53
52
|
assert_equal 1, results.count
|
54
53
|
|
55
|
-
assert_instance_of
|
54
|
+
assert_instance_of Results::Item, results.first
|
56
55
|
assert_equal 'Test', results.first.title
|
57
56
|
assert_not_nil results.first._score
|
58
57
|
assert_equal id, results.first.id
|
@@ -61,11 +60,12 @@ module Tire
|
|
61
60
|
should "remove document from index on destroy" do
|
62
61
|
a = SupermodelArticle.new :title => 'Test'
|
63
62
|
a.save
|
63
|
+
assert_equal 1, SupermodelArticle.all.size
|
64
|
+
|
64
65
|
a.destroy
|
66
|
+
assert_equal 0, SupermodelArticle.all.size
|
65
67
|
|
66
68
|
a.index.refresh
|
67
|
-
sleep(1.25)
|
68
|
-
|
69
69
|
results = SupermodelArticle.search 'test'
|
70
70
|
|
71
71
|
assert_equal 0, results.count
|
@@ -84,6 +84,27 @@ module Tire
|
|
84
84
|
assert_equal 'abc123', results.first.id
|
85
85
|
end
|
86
86
|
|
87
|
+
context "within Rails" do
|
88
|
+
|
89
|
+
setup do
|
90
|
+
module ::Rails; end
|
91
|
+
end
|
92
|
+
|
93
|
+
should "load the underlying model" do
|
94
|
+
a = SupermodelArticle.new :title => 'Test'
|
95
|
+
a.save
|
96
|
+
a.index.refresh
|
97
|
+
|
98
|
+
results = SupermodelArticle.search 'test'
|
99
|
+
|
100
|
+
assert_instance_of Results::Item, results.first
|
101
|
+
assert_instance_of SupermodelArticle, results.first.load
|
102
|
+
|
103
|
+
assert_equal 'Test', results.first.load.title
|
104
|
+
end
|
105
|
+
|
106
|
+
end
|
107
|
+
|
87
108
|
end
|
88
109
|
|
89
110
|
end
|
@@ -15,19 +15,25 @@ module Tire
|
|
15
15
|
t.string :title
|
16
16
|
t.datetime :created_at, :default => 'NOW()'
|
17
17
|
end
|
18
|
+
create_table :active_record_comments do |t|
|
19
|
+
t.string :author
|
20
|
+
t.text :body
|
21
|
+
t.references :article
|
22
|
+
t.timestamps
|
23
|
+
end
|
24
|
+
create_table :active_record_stats do |t|
|
25
|
+
t.integer :pageviews
|
26
|
+
t.string :period
|
27
|
+
t.references :article
|
28
|
+
end
|
18
29
|
end
|
19
30
|
end
|
20
31
|
|
21
|
-
def teardown
|
22
|
-
super
|
23
|
-
File.delete fixtures_path.join('articles.db') rescue nil
|
24
|
-
end
|
25
|
-
|
26
32
|
context "ActiveRecord integration" do
|
27
33
|
|
28
34
|
setup do
|
29
35
|
Tire.index('active_record_articles').delete
|
30
|
-
load File.expand_path('../../models/
|
36
|
+
load File.expand_path('../../models/active_record_models.rb', __FILE__)
|
31
37
|
end
|
32
38
|
teardown { Tire.index('active_record_articles').delete }
|
33
39
|
|
@@ -44,24 +50,65 @@ module Tire
|
|
44
50
|
id = a.id
|
45
51
|
|
46
52
|
a.index.refresh
|
47
|
-
sleep(1.5) # Leave ES some breathing room here...
|
48
53
|
|
49
54
|
results = ActiveRecordArticle.search 'test'
|
50
55
|
|
56
|
+
assert results.any?
|
51
57
|
assert_equal 1, results.count
|
52
58
|
|
53
|
-
assert_instance_of
|
59
|
+
assert_instance_of Results::Item, results.first
|
54
60
|
assert_not_nil results.first.id
|
55
|
-
assert_equal id, results.first.id
|
61
|
+
assert_equal id.to_s, results.first.id.to_s
|
56
62
|
assert results.first.persisted?, "Record should be persisted"
|
57
63
|
assert_not_nil results.first._score
|
58
64
|
assert_equal 'Test', results.first.title
|
59
65
|
end
|
60
66
|
|
67
|
+
context "with eager loading" do
|
68
|
+
setup do
|
69
|
+
ActiveRecordArticle.destroy_all
|
70
|
+
5.times { |n| ActiveRecordArticle.create! :title => "Test #{n+1}" }
|
71
|
+
ActiveRecordArticle.elasticsearch_index.refresh
|
72
|
+
end
|
73
|
+
|
74
|
+
should "load records on query search" do
|
75
|
+
results = ActiveRecordArticle.search '"Test 1"', :load => true
|
76
|
+
|
77
|
+
assert results.any?
|
78
|
+
assert_equal ActiveRecordArticle.find(1), results.first
|
79
|
+
end
|
80
|
+
|
81
|
+
should "load records on block search" do
|
82
|
+
results = ActiveRecordArticle.search :load => true do
|
83
|
+
query { string '"Test 1"' }
|
84
|
+
end
|
85
|
+
|
86
|
+
assert_equal ActiveRecordArticle.find(1), results.first
|
87
|
+
end
|
88
|
+
|
89
|
+
should "load records with options on query search" do
|
90
|
+
assert_equal ActiveRecordArticle.find(['1', '2'], :include => 'comments'),
|
91
|
+
ActiveRecordArticle.search('"Test 1" OR "Test 2"', :load => { :include => 'comments' }).results
|
92
|
+
end
|
93
|
+
|
94
|
+
should "return empty collection for nonmatching query" do
|
95
|
+
assert_nothing_raised do
|
96
|
+
results = ActiveRecordArticle.search :load => true do
|
97
|
+
query { string '"Hic Sunt Leones"' }
|
98
|
+
end
|
99
|
+
assert_equal 0, results.size
|
100
|
+
assert ! results.any?
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
61
105
|
should "remove document from index on destroy" do
|
62
106
|
a = ActiveRecordArticle.new :title => 'Test'
|
63
107
|
a.save!
|
108
|
+
assert_equal 1, ActiveRecordArticle.count
|
109
|
+
|
64
110
|
a.destroy
|
111
|
+
assert_equal 0, SupermodelArticle.all.size
|
65
112
|
|
66
113
|
a.index.refresh
|
67
114
|
results = ActiveRecordArticle.search 'test'
|
@@ -184,6 +231,44 @@ module Tire
|
|
184
231
|
|
185
232
|
end
|
186
233
|
|
234
|
+
context "within Rails" do
|
235
|
+
|
236
|
+
setup do
|
237
|
+
module ::Rails; end
|
238
|
+
|
239
|
+
a = ActiveRecordArticle.new :title => 'Test'
|
240
|
+
a.comments.build :author => 'fool', :body => 'Works!'
|
241
|
+
a.stats.build :pageviews => 12, :period => '2011-08'
|
242
|
+
a.save!
|
243
|
+
@id = a.id.to_s
|
244
|
+
|
245
|
+
a.index.refresh
|
246
|
+
@item = ActiveRecordArticle.search('test').first
|
247
|
+
end
|
248
|
+
|
249
|
+
should "have access to indexed properties" do
|
250
|
+
assert_equal 'Test', @item.title
|
251
|
+
assert_equal 'fool', @item.comments.first.author
|
252
|
+
assert_equal 12, @item.stats.first.pageviews
|
253
|
+
end
|
254
|
+
|
255
|
+
should "load the underlying models" do
|
256
|
+
assert_instance_of Results::Item, @item
|
257
|
+
assert_instance_of ActiveRecordArticle, @item.load
|
258
|
+
assert_equal 'Test', @item.load.title
|
259
|
+
|
260
|
+
assert_instance_of Results::Item, @item.comments.first
|
261
|
+
assert_instance_of ActiveRecordComment, @item.comments.first.load
|
262
|
+
assert_equal 'fool', @item.comments.first.load.author
|
263
|
+
end
|
264
|
+
|
265
|
+
should "load the underlying model with options" do
|
266
|
+
ActiveRecordArticle.expects(:find).with(@id, :include => 'comments')
|
267
|
+
@item.load(:include => 'comments')
|
268
|
+
end
|
269
|
+
|
270
|
+
end
|
271
|
+
|
187
272
|
end
|
188
273
|
|
189
274
|
end
|