tire 0.1.16 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
@@ -6,7 +6,7 @@ module Tire
|
|
6
6
|
include Test::Integration
|
7
7
|
|
8
8
|
context "Default mapping" do
|
9
|
-
teardown { Tire.index('mapped-index').delete }
|
9
|
+
teardown { Tire.index('mapped-index').delete; sleep 0.1 }
|
10
10
|
|
11
11
|
should "create and return the default mapping" do
|
12
12
|
|
@@ -14,8 +14,8 @@ module Tire
|
|
14
14
|
create
|
15
15
|
store :type => :article, :title => 'One'
|
16
16
|
refresh
|
17
|
+
sleep 1
|
17
18
|
end
|
18
|
-
sleep 1.5
|
19
19
|
|
20
20
|
assert_equal 'string', index.mapping['article']['properties']['title']['type'], index.mapping.inspect
|
21
21
|
assert_nil index.mapping['article']['properties']['title']['boost'], index.mapping.inspect
|
@@ -23,14 +23,13 @@ module Tire
|
|
23
23
|
end
|
24
24
|
|
25
25
|
context "Creating index with mapping" do
|
26
|
-
teardown { Tire.index('mapped-index').delete; sleep 1 }
|
26
|
+
teardown { Tire.index('mapped-index').delete; sleep 0.1 }
|
27
27
|
|
28
28
|
should "create the specified mapping" do
|
29
29
|
|
30
30
|
index = Tire.index 'mapped-index' do
|
31
31
|
create :mappings => { :article => { :properties => { :title => { :type => 'string', :boost => 2.0, :store => 'yes' } } } }
|
32
32
|
end
|
33
|
-
sleep 1
|
34
33
|
|
35
34
|
# p index.mapping
|
36
35
|
assert_equal 2.0, index.mapping['article']['properties']['title']['boost'], index.mapping.inspect
|
@@ -20,7 +20,7 @@ module Tire
|
|
20
20
|
should "register query as a Hash" do
|
21
21
|
query = { :query => { :query_string => { :query => 'warning' } } }
|
22
22
|
assert @index.register_percolator_query('alert', query)
|
23
|
-
Tire.index('_percolator').refresh
|
23
|
+
Tire.index('_percolator').refresh
|
24
24
|
|
25
25
|
percolator = Configuration.client.get("#{Configuration.url}/_percolator/percolator-test/alert")
|
26
26
|
assert percolator
|
@@ -28,7 +28,7 @@ module Tire
|
|
28
28
|
|
29
29
|
should "register query as block" do
|
30
30
|
assert @index.register_percolator_query('alert') { string 'warning' }
|
31
|
-
Tire.index('_percolator').refresh
|
31
|
+
Tire.index('_percolator').refresh
|
32
32
|
|
33
33
|
percolator = Configuration.client.get("#{Configuration.url}/_percolator/percolator-test/alert")
|
34
34
|
assert percolator
|
@@ -37,11 +37,11 @@ module Tire
|
|
37
37
|
should "unregister a query" do
|
38
38
|
query = { :query => { :query_string => { :query => 'warning' } } }
|
39
39
|
assert @index.register_percolator_query('alert', query)
|
40
|
-
Tire.index('_percolator').refresh
|
40
|
+
Tire.index('_percolator').refresh
|
41
41
|
assert Configuration.client.get("#{Configuration.url}/_percolator/percolator-test/alert")
|
42
42
|
|
43
43
|
assert @index.unregister_percolator_query('alert')
|
44
|
-
Tire.index('_percolator').refresh
|
44
|
+
Tire.index('_percolator').refresh
|
45
45
|
|
46
46
|
assert_raise(RestClient::ResourceNotFound) do
|
47
47
|
Configuration.client.get("#{Configuration.url}/_percolator/percolator-test/alert")
|
@@ -55,7 +55,7 @@ module Tire
|
|
55
55
|
@index.register_percolator_query('alert') { string 'warning' }
|
56
56
|
@index.register_percolator_query('gantz') { string '"y u no match"' }
|
57
57
|
@index.register_percolator_query('weather', :tags => ['weather']) { string 'severe' }
|
58
|
-
Tire.index('_percolator').refresh
|
58
|
+
Tire.index('_percolator').refresh
|
59
59
|
end
|
60
60
|
|
61
61
|
should "return an empty array when no query matches" do
|
@@ -79,7 +79,7 @@ module Tire
|
|
79
79
|
@index.register_percolator_query('alert') { string 'warning' }
|
80
80
|
@index.register_percolator_query('gantz') { string '"y u no match"' }
|
81
81
|
@index.register_percolator_query('weather', :tags => ['weather']) { string 'severe' }
|
82
|
-
Tire.index('_percolator').refresh
|
82
|
+
Tire.index('_percolator').refresh
|
83
83
|
end
|
84
84
|
|
85
85
|
should "return an empty array when no query matches" do
|
@@ -22,13 +22,31 @@ module Tire
|
|
22
22
|
two = PersistentArticle.create :id => 2, :title => 'Two'
|
23
23
|
|
24
24
|
PersistentArticle.index.refresh
|
25
|
-
sleep(1.5)
|
26
25
|
|
27
26
|
results = PersistentArticle.find [1, 2]
|
28
27
|
|
29
28
|
assert_equal 2, results.size
|
30
29
|
|
31
30
|
end
|
31
|
+
|
32
|
+
context "with pagination" do
|
33
|
+
|
34
|
+
setup do
|
35
|
+
1.upto(9) { |number| PersistentArticle.create :title => "Test#{number}" }
|
36
|
+
PersistentArticle.elasticsearch_index.refresh
|
37
|
+
end
|
38
|
+
|
39
|
+
should "find first page with five results" do
|
40
|
+
results = PersistentArticle.search( :per_page => 5, :page => 1 ) { query { all } }
|
41
|
+
assert_equal 5, results.size
|
42
|
+
|
43
|
+
assert_equal 2, results.total_pages
|
44
|
+
assert_equal 1, results.current_page
|
45
|
+
assert_equal nil, results.previous_page
|
46
|
+
assert_equal 2, results.next_page
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
32
50
|
end
|
33
51
|
|
34
52
|
end
|
@@ -30,6 +30,15 @@ module Tire
|
|
30
30
|
assert_equal 4, search(q).results.count
|
31
31
|
end
|
32
32
|
|
33
|
+
should "pass options to query definition" do
|
34
|
+
s = Tire.search 'articles-test' do
|
35
|
+
query do
|
36
|
+
string 'ruby python', :default_operator => 'AND'
|
37
|
+
end
|
38
|
+
end
|
39
|
+
assert_equal 1, s.results.count
|
40
|
+
end
|
41
|
+
|
33
42
|
end
|
34
43
|
|
35
44
|
private
|
@@ -21,6 +21,17 @@ module Tire
|
|
21
21
|
assert_nil s.results.first.tags
|
22
22
|
end
|
23
23
|
|
24
|
+
should "allow to retrieve multiple fields" do
|
25
|
+
q = 'title:one'
|
26
|
+
s = Tire.search('articles-test') do
|
27
|
+
query { string q }
|
28
|
+
fields 'title', 'tags'
|
29
|
+
end
|
30
|
+
assert_equal 'One', s.results.first.title
|
31
|
+
assert_equal 'ruby', s.results.first.tags[0]
|
32
|
+
assert_nil s.results.first.published_on
|
33
|
+
end
|
34
|
+
|
24
35
|
end
|
25
36
|
|
26
37
|
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'active_record'
|
3
|
+
|
4
|
+
class ActiveRecordArticle < ActiveRecord::Base
|
5
|
+
has_many :comments, :class_name => "ActiveRecordComment", :foreign_key => "article_id"
|
6
|
+
has_many :stats, :class_name => "ActiveRecordStat", :foreign_key => "article_id"
|
7
|
+
|
8
|
+
include Tire::Model::Search
|
9
|
+
include Tire::Model::Callbacks
|
10
|
+
|
11
|
+
mapping do
|
12
|
+
indexes :title, :type => 'string', :boost => 10, :analyzer => 'snowball'
|
13
|
+
indexes :created_at, :type => 'date'
|
14
|
+
|
15
|
+
indexes :comments do
|
16
|
+
indexes :author
|
17
|
+
indexes :body
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_indexed_json
|
22
|
+
{
|
23
|
+
:title => title,
|
24
|
+
:length => length,
|
25
|
+
|
26
|
+
:comments => comments.map { |c| { :_type => 'active_record_comment',
|
27
|
+
:_id => c.id,
|
28
|
+
:author => c.author,
|
29
|
+
:body => c.body } },
|
30
|
+
:stats => stats.map { |s| { :pageviews => s.pageviews } }
|
31
|
+
}.to_json
|
32
|
+
end
|
33
|
+
|
34
|
+
def length
|
35
|
+
title.length
|
36
|
+
end
|
37
|
+
|
38
|
+
def comment_authors
|
39
|
+
comments.map(&:author).to_sentence
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
class ActiveRecordComment < ActiveRecord::Base
|
44
|
+
belongs_to :article, :class_name => "ActiveRecordArticle", :foreign_key => "article_id"
|
45
|
+
end
|
46
|
+
|
47
|
+
class ActiveRecordStat < ActiveRecord::Base
|
48
|
+
belongs_to :article, :class_name => "ActiveRecordArticle", :foreign_key => "article_id"
|
49
|
+
end
|
data/test/test_helper.rb
CHANGED
data/test/unit/index_test.rb
CHANGED
@@ -181,7 +181,10 @@ module Tire
|
|
181
181
|
setup do
|
182
182
|
Configuration.reset :wrapper
|
183
183
|
|
184
|
-
Configuration.client.stubs(:post).with
|
184
|
+
Configuration.client.stubs(:post).with do |url, payload|
|
185
|
+
url == "#{Configuration.url}/dummy/article/" &&
|
186
|
+
payload =~ /"title":"Test"/
|
187
|
+
end.
|
185
188
|
returns(mock_response('{"ok":true,"_id":"id-1"}'))
|
186
189
|
@index.store :type => 'article', :title => 'Test'
|
187
190
|
end
|
@@ -319,7 +322,7 @@ module Tire
|
|
319
322
|
end
|
320
323
|
end
|
321
324
|
|
322
|
-
should "
|
325
|
+
should "display error message when collection item does not have ID" do
|
323
326
|
Configuration.client.expects(:post).with { |url, json| url == "#{Configuration.url}/_bulk" }
|
324
327
|
STDERR.expects(:puts).once
|
325
328
|
|
@@ -487,7 +490,7 @@ module Tire
|
|
487
490
|
Configuration.client.expects(:put).with do |url, payload|
|
488
491
|
payload = MultiJson.decode(payload)
|
489
492
|
url == "#{Configuration.url}/_percolator/dummy/my-query" &&
|
490
|
-
payload['query']['query_string']['query'] == 'foo'
|
493
|
+
payload['query']['query_string']['query'] == 'foo' &&
|
491
494
|
payload['tags'] == ['alert']
|
492
495
|
end.
|
493
496
|
returns(mock_response('{
|
@@ -548,13 +551,21 @@ module Tire
|
|
548
551
|
context "while storing document" do
|
549
552
|
|
550
553
|
should "percolate document against all registered queries" do
|
551
|
-
Configuration.client.expects(:post).
|
554
|
+
Configuration.client.expects(:post).
|
555
|
+
with do |url, payload|
|
556
|
+
url == "#{Configuration.url}/dummy/article/?percolate=*" &&
|
557
|
+
payload =~ /"title":"Test"/
|
558
|
+
end.
|
552
559
|
returns(mock_response('{"ok":true,"_id":"test","matches":["alerts"]}'))
|
553
560
|
@index.store( {:type => 'article', :title => 'Test'}, {:percolate => true} )
|
554
561
|
end
|
555
562
|
|
556
563
|
should "percolate document against specific queries" do
|
557
|
-
Configuration.client.expects(:post).
|
564
|
+
Configuration.client.expects(:post).
|
565
|
+
with do |url, payload|
|
566
|
+
url == "#{Configuration.url}/dummy/article/?percolate=tag:alerts" &&
|
567
|
+
payload =~ /"title":"Test"/
|
568
|
+
end.
|
558
569
|
returns(mock_response('{"ok":true,"_id":"test","matches":["alerts"]}'))
|
559
570
|
response = @index.store( {:type => 'article', :title => 'Test'}, {:percolate => 'tag:alerts'} )
|
560
571
|
assert_equal response['matches'], ['alerts']
|
@@ -90,20 +90,18 @@ module Tire
|
|
90
90
|
ActiveModelArticle.elasticsearch_index.refresh
|
91
91
|
end
|
92
92
|
|
93
|
-
should "wrap results in
|
93
|
+
should "wrap results in instances of the wrapper class" do
|
94
94
|
response = { 'hits' => { 'hits' => [{'_id' => 1, '_score' => 0.8, '_source' => { 'title' => 'Article' }}] } }
|
95
95
|
Configuration.client.expects(:get).returns(mock_response(response.to_json))
|
96
96
|
|
97
97
|
collection = ActiveModelArticle.search 'foo'
|
98
98
|
assert_instance_of Results::Collection, collection
|
99
99
|
|
100
|
-
assert_equal Results::Item, Tire::Configuration.wrapper
|
101
|
-
|
102
100
|
document = collection.first
|
103
101
|
|
104
|
-
assert_instance_of
|
105
|
-
assert_not_nil
|
106
|
-
assert_equal 1,
|
102
|
+
assert_instance_of Results::Item, document
|
103
|
+
assert_not_nil document._score
|
104
|
+
assert_equal 1, document.id
|
107
105
|
assert_equal 'Article', document.title
|
108
106
|
end
|
109
107
|
|
@@ -129,6 +127,15 @@ module Tire
|
|
129
127
|
end
|
130
128
|
end
|
131
129
|
|
130
|
+
should "allow to pass :page and :per_page options" do
|
131
|
+
Tire::Search::Search.any_instance.expects(:size).with(10)
|
132
|
+
Tire::Search::Search.any_instance.expects(:from).with(20)
|
133
|
+
|
134
|
+
ActiveModelArticle.search :per_page => 10, :page => 3 do
|
135
|
+
query { string 'foo' }
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
132
139
|
end
|
133
140
|
|
134
141
|
context "searching with query string" do
|
@@ -551,6 +558,38 @@ module Tire
|
|
551
558
|
|
552
559
|
end
|
553
560
|
|
561
|
+
context "Results::Item" do
|
562
|
+
|
563
|
+
setup do
|
564
|
+
module ::Rails
|
565
|
+
end
|
566
|
+
|
567
|
+
class ::FakeRailsModel
|
568
|
+
extend ActiveModel::Naming
|
569
|
+
include ActiveModel::Conversion
|
570
|
+
def self.find(*args); new; end
|
571
|
+
end
|
572
|
+
|
573
|
+
@document = Results::Item.new :id => 1, :_type => 'fake_rails_model', :title => 'Test'
|
574
|
+
end
|
575
|
+
|
576
|
+
should "load the 'real' instance from the corresponding model" do
|
577
|
+
assert_respond_to @document, :load
|
578
|
+
assert_instance_of FakeRailsModel, @document.load
|
579
|
+
end
|
580
|
+
|
581
|
+
should "pass the ID to the corresponding model's find method" do
|
582
|
+
FakeRailsModel.expects(:find).with(1).returns(FakeRailsModel.new)
|
583
|
+
@document.load
|
584
|
+
end
|
585
|
+
|
586
|
+
should "pass the options to the corresponding model's find method" do
|
587
|
+
FakeRailsModel.expects(:find).with(1, {:include => 'everything'}).returns(FakeRailsModel.new)
|
588
|
+
@document.load :include => 'everything'
|
589
|
+
end
|
590
|
+
|
591
|
+
end
|
592
|
+
|
554
593
|
end
|
555
594
|
|
556
595
|
end
|
@@ -6,7 +6,8 @@ module Tire
|
|
6
6
|
|
7
7
|
context "Collection" do
|
8
8
|
setup do
|
9
|
-
|
9
|
+
begin; Object.send(:remove_const, :Rails); rescue; end
|
10
|
+
Configuration.reset
|
10
11
|
@default_response = { 'hits' => { 'hits' => [{'_id' => 1, '_score' => 1, '_source' => {:title => 'Test'}},
|
11
12
|
{'_id' => 2},
|
12
13
|
{'_id' => 3}] } }
|
@@ -110,7 +111,7 @@ module Tire
|
|
110
111
|
# Underlying issue: https://github.com/karmi/tire/pull/31#issuecomment-1340967
|
111
112
|
#
|
112
113
|
setup do
|
113
|
-
Configuration.reset
|
114
|
+
Configuration.reset
|
114
115
|
@default_response = { 'hits' => { 'hits' =>
|
115
116
|
[ { '_id' => 1, '_score' => 0.5, '_index' => 'testing', '_type' => 'article',
|
116
117
|
'fields' => {
|
@@ -187,6 +188,54 @@ module Tire
|
|
187
188
|
|
188
189
|
end
|
189
190
|
|
191
|
+
context "with eager loading" do
|
192
|
+
setup do
|
193
|
+
@response = { 'hits' => { 'hits' => [ {'_id' => 1, '_type' => 'active_record_article'},
|
194
|
+
{'_id' => 2, '_type' => 'active_record_article'},
|
195
|
+
{'_id' => 3, '_type' => 'active_record_article'}] } }
|
196
|
+
ActiveRecordArticle.stubs(:inspect).returns("<ActiveRecordArticle>")
|
197
|
+
end
|
198
|
+
|
199
|
+
should "load the records via model find method from database" do
|
200
|
+
ActiveRecordArticle.expects(:find).with([1,2,3]).
|
201
|
+
returns([ Results::Item.new(:id => 3),
|
202
|
+
Results::Item.new(:id => 1),
|
203
|
+
Results::Item.new(:id => 2) ])
|
204
|
+
Results::Collection.new(@response, :load => true).results
|
205
|
+
end
|
206
|
+
|
207
|
+
should "pass the :load option Hash to model find metod" do
|
208
|
+
ActiveRecordArticle.expects(:find).with([1,2,3], :include => 'comments').
|
209
|
+
returns([ Results::Item.new(:id => 3),
|
210
|
+
Results::Item.new(:id => 1),
|
211
|
+
Results::Item.new(:id => 2) ])
|
212
|
+
Results::Collection.new(@response, :load => { :include => 'comments' }).results
|
213
|
+
end
|
214
|
+
|
215
|
+
should "preserve the order of records returned from search" do
|
216
|
+
ActiveRecordArticle.expects(:find).with([1,2,3]).
|
217
|
+
returns([ Results::Item.new(:id => 3),
|
218
|
+
Results::Item.new(:id => 1),
|
219
|
+
Results::Item.new(:id => 2) ])
|
220
|
+
assert_equal [1,2,3], Results::Collection.new(@response, :load => true).results.map(&:id)
|
221
|
+
end
|
222
|
+
|
223
|
+
should "raise error when model class cannot be inferred from _type" do
|
224
|
+
assert_raise(NameError) do
|
225
|
+
response = { 'hits' => { 'hits' => [ {'_id' => 1, '_type' => 'hic_sunt_leones'}] } }
|
226
|
+
Results::Collection.new(response, :load => true).results
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
should "raise error when _type is missing" do
|
231
|
+
assert_raise(NoMethodError) do
|
232
|
+
response = { 'hits' => { 'hits' => [ {'_id' => 1}] } }
|
233
|
+
Results::Collection.new(response, :load => true).results
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
end
|
238
|
+
|
190
239
|
end
|
191
240
|
|
192
241
|
end
|
@@ -1,9 +1,17 @@
|
|
1
1
|
require 'test_helper'
|
2
2
|
|
3
3
|
module Tire
|
4
|
-
|
5
4
|
class ResultsItemTest < Test::Unit::TestCase
|
6
5
|
|
6
|
+
# ActiveModel compatibility tests
|
7
|
+
#
|
8
|
+
def setup
|
9
|
+
super
|
10
|
+
begin; Object.send(:remove_const, :Rails); rescue; end
|
11
|
+
@model = Results::Item.new :title => 'Test'
|
12
|
+
end
|
13
|
+
include ActiveModel::Lint::Tests
|
14
|
+
|
7
15
|
context "Item" do
|
8
16
|
|
9
17
|
setup do
|
@@ -57,6 +65,61 @@ module Tire
|
|
57
65
|
assert_equal 'Kafka', @document.author.name
|
58
66
|
end
|
59
67
|
|
68
|
+
should "wrap arrays" do
|
69
|
+
@document = Results::Item.new :stats => [1, 2, 3]
|
70
|
+
assert_equal [1, 2, 3], @document.stats
|
71
|
+
end
|
72
|
+
|
73
|
+
should "wrap hashes in arrays" do
|
74
|
+
@document = Results::Item.new :comments => [{:title => 'one'}, {:title => 'two'}]
|
75
|
+
assert_equal 2, @document.comments.size
|
76
|
+
assert_instance_of Results::Item, @document.comments.first
|
77
|
+
assert_equal 'one', @document.comments.first.title
|
78
|
+
assert_equal 'two', @document.comments.last.title
|
79
|
+
end
|
80
|
+
|
81
|
+
should "be an Item instance" do
|
82
|
+
assert_instance_of Tire::Results::Item, @document
|
83
|
+
end
|
84
|
+
|
85
|
+
should "be convertible to hash" do
|
86
|
+
assert_instance_of Hash, @document.to_hash
|
87
|
+
end
|
88
|
+
|
89
|
+
should "be inspectable" do
|
90
|
+
assert_match /<Item title|Item author/, @document.inspect
|
91
|
+
end
|
92
|
+
|
93
|
+
context "within Rails" do
|
94
|
+
|
95
|
+
setup do
|
96
|
+
module ::Rails
|
97
|
+
end
|
98
|
+
|
99
|
+
class ::FakeRailsModel
|
100
|
+
extend ActiveModel::Naming
|
101
|
+
include ActiveModel::Conversion
|
102
|
+
def self.find(id, options); new; end
|
103
|
+
end
|
104
|
+
|
105
|
+
@document = Results::Item.new :id => 1, :_type => 'fake_rails_model', :title => 'Test'
|
106
|
+
end
|
107
|
+
|
108
|
+
should "be an instance of model, based on _type" do
|
109
|
+
assert_equal FakeRailsModel, @document.class
|
110
|
+
end
|
111
|
+
|
112
|
+
should "be inspectable with masquerade" do
|
113
|
+
assert_match /<Item \(FakeRailsModel\)/, @document.inspect
|
114
|
+
end
|
115
|
+
|
116
|
+
should "return proper singular and plural forms" do
|
117
|
+
assert_equal 'fake_rails_model', ActiveModel::Naming.singular(@document)
|
118
|
+
assert_equal 'fake_rails_models', ActiveModel::Naming.plural(@document)
|
119
|
+
end
|
120
|
+
|
121
|
+
end
|
122
|
+
|
60
123
|
end
|
61
124
|
|
62
125
|
end
|