praxis-mapper 3.1.1
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.
- checksums.yaml +7 -0
- data/.gitignore +26 -0
- data/.rspec +3 -0
- data/.travis.yml +4 -0
- data/CHANGELOG.md +83 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +102 -0
- data/Guardfile +11 -0
- data/LICENSE +22 -0
- data/README.md +19 -0
- data/Rakefile +14 -0
- data/lib/praxis-mapper/config_hash.rb +40 -0
- data/lib/praxis-mapper/connection_manager.rb +102 -0
- data/lib/praxis-mapper/finalizable.rb +38 -0
- data/lib/praxis-mapper/identity_map.rb +532 -0
- data/lib/praxis-mapper/logging.rb +22 -0
- data/lib/praxis-mapper/model.rb +430 -0
- data/lib/praxis-mapper/query/base.rb +213 -0
- data/lib/praxis-mapper/query/sql.rb +183 -0
- data/lib/praxis-mapper/query_statistics.rb +46 -0
- data/lib/praxis-mapper/resource.rb +226 -0
- data/lib/praxis-mapper/support/factory_girl.rb +104 -0
- data/lib/praxis-mapper/support/memory_query.rb +34 -0
- data/lib/praxis-mapper/support/memory_repository.rb +44 -0
- data/lib/praxis-mapper/support/schema_dumper.rb +66 -0
- data/lib/praxis-mapper/support/schema_loader.rb +56 -0
- data/lib/praxis-mapper/support.rb +2 -0
- data/lib/praxis-mapper/version.rb +5 -0
- data/lib/praxis-mapper.rb +60 -0
- data/praxis-mapper.gemspec +38 -0
- data/spec/praxis-mapper/connection_manager_spec.rb +117 -0
- data/spec/praxis-mapper/identity_map_spec.rb +905 -0
- data/spec/praxis-mapper/logging_spec.rb +9 -0
- data/spec/praxis-mapper/memory_repository_spec.rb +56 -0
- data/spec/praxis-mapper/model_spec.rb +389 -0
- data/spec/praxis-mapper/query/base_spec.rb +317 -0
- data/spec/praxis-mapper/query/sql_spec.rb +184 -0
- data/spec/praxis-mapper/resource_spec.rb +154 -0
- data/spec/praxis_mapper_spec.rb +21 -0
- data/spec/spec_fixtures.rb +12 -0
- data/spec/spec_helper.rb +63 -0
- data/spec/support/spec_models.rb +215 -0
- data/spec/support/spec_resources.rb +39 -0
- metadata +298 -0
@@ -0,0 +1,532 @@
|
|
1
|
+
# An identity map that tracks data that's been loaded, and data that we still need to load.
|
2
|
+
# As tables are loaded and processed, the identity map will keep a list of child models that have been "seen" and will need to be loaded for the final view.
|
3
|
+
# The identity map defines a scope for the associated queries.
|
4
|
+
# The scope can be thought of as a set of named filters.
|
5
|
+
module Praxis::Mapper
|
6
|
+
class IdentityMap
|
7
|
+
|
8
|
+
class UnloadedRecordException < StandardError; end;
|
9
|
+
class UnsupportedModel < StandardError; end;
|
10
|
+
class UnknownIdentity < StandardError; end;
|
11
|
+
|
12
|
+
attr_reader :unloaded, :queries, :blueprint_cache
|
13
|
+
attr_accessor :scope
|
14
|
+
|
15
|
+
class << self
|
16
|
+
attr_accessor :config
|
17
|
+
end
|
18
|
+
|
19
|
+
|
20
|
+
# Stores given identity map in a thread-local variable
|
21
|
+
# @param [IdentityMap] some identity map
|
22
|
+
def self.current=(identity_map)
|
23
|
+
Thread.current[:_praxis_mapper_identity_map] = identity_map
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
# @return [IdentityMap] current identity map from thread-local variable
|
28
|
+
def self.current
|
29
|
+
map = Thread.current[:_praxis_mapper_identity_map]
|
30
|
+
raise "current IdentityMap not set" unless map
|
31
|
+
map
|
32
|
+
end
|
33
|
+
|
34
|
+
|
35
|
+
# @return [Boolean] whether identity map thread-local variable has been set
|
36
|
+
def self.current?
|
37
|
+
Thread.current.key?(:_praxis_mapper_identity_map) && Thread.current[:_praxis_mapper_identity_map].kind_of?(Praxis::Mapper::IdentityMap)
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
def clear?
|
42
|
+
@rows.empty? &&
|
43
|
+
@staged.empty? &&
|
44
|
+
@row_keys.empty? &&
|
45
|
+
@queries.empty?
|
46
|
+
end
|
47
|
+
|
48
|
+
|
49
|
+
# TODO: how come scope can be set from 3 different methods?
|
50
|
+
#
|
51
|
+
# @param scope [Hash] a set of named filters to apply in query
|
52
|
+
# @example {:account => [:account_id, 71], :user => [:user_id, 2]}
|
53
|
+
#
|
54
|
+
def self.setup!(scope={})
|
55
|
+
if self.current?
|
56
|
+
if !self.current.clear?
|
57
|
+
raise "Denied for a pre-existing condition: Identity map has been used."
|
58
|
+
else
|
59
|
+
self.current.scope = scope
|
60
|
+
return self.current
|
61
|
+
end
|
62
|
+
else
|
63
|
+
self.current = self.new(scope)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# TODO: support multiple connections
|
68
|
+
def initialize(scope={})
|
69
|
+
@connection_manager = ConnectionManager.new
|
70
|
+
@scope = scope
|
71
|
+
clear!
|
72
|
+
end
|
73
|
+
|
74
|
+
def clear!
|
75
|
+
@rows = Hash.new { |h,k| h[k] = Array.new }
|
76
|
+
|
77
|
+
# for ex:
|
78
|
+
# @staged[Instance][:id] = Set.new
|
79
|
+
# yields:
|
80
|
+
# {Instance => {:id => Set.new(1,2,3), :name => Set.new("George Jr.") } }
|
81
|
+
@staged = Hash.new do |hash,model|
|
82
|
+
hash[model] = Hash.new do |identity_hash, identity_name|
|
83
|
+
identity_hash[identity_name] = Set.new
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# for ex:
|
88
|
+
# @row_keys["instances"][:id][1] = Object.new
|
89
|
+
# yields:
|
90
|
+
# {"instances"=>{:id=>{1=>Object.new}}
|
91
|
+
@row_keys = Hash.new do |row_hash,model|
|
92
|
+
row_hash[model] = Hash.new do |primary_keys, key_name|
|
93
|
+
primary_keys[key_name] = Hash.new
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
@queries = Hash.new { |h,k| h[k] = Set.new }
|
98
|
+
|
99
|
+
# see how it feels to store blueprints here
|
100
|
+
# for ex:
|
101
|
+
# @blueprints[User][some_object] = User.new(some_object)
|
102
|
+
@blueprint_cache = Hash.new do |cache,blueprint_class|
|
103
|
+
cache[blueprint_class] = Hash.new
|
104
|
+
end
|
105
|
+
|
106
|
+
# TODO: rework this so it's a hash with default values and simplify #index
|
107
|
+
@secondary_indexes = Hash.new
|
108
|
+
end
|
109
|
+
|
110
|
+
def load(model, &block)
|
111
|
+
raise "Can't load unfinalized model #{model}" unless model.finalized?
|
112
|
+
|
113
|
+
query_class = @connection_manager.repository(model.repository_name)[:query]
|
114
|
+
query = query_class.new(self, model, &block)
|
115
|
+
|
116
|
+
return finalize_model!(model, query) if query.where == :staged
|
117
|
+
|
118
|
+
records = query.execute
|
119
|
+
add_records(records)
|
120
|
+
|
121
|
+
# TODO: refactor this to better-hide queries?
|
122
|
+
query.freeze
|
123
|
+
self.queries[model].add(query)
|
124
|
+
|
125
|
+
subload(model, query,records)
|
126
|
+
|
127
|
+
records
|
128
|
+
end
|
129
|
+
|
130
|
+
def stage_for!(spec, records)
|
131
|
+
case spec[:type]
|
132
|
+
when :many_to_one
|
133
|
+
stage_many_to_one(spec, records)
|
134
|
+
when :array_to_many
|
135
|
+
stage_array_to_many(spec, records)
|
136
|
+
when :one_to_many
|
137
|
+
stage_one_to_many(spec, records)
|
138
|
+
when :many_to_array
|
139
|
+
stage_many_to_array(spec, records)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def subload(model, query, records)
|
144
|
+
query.load.each do |(association_name, block)|
|
145
|
+
spec = model.associations.fetch(association_name)
|
146
|
+
|
147
|
+
associated_model = spec[:model]
|
148
|
+
|
149
|
+
key, values = stage_for!(spec, records)
|
150
|
+
|
151
|
+
existing_records = []
|
152
|
+
values.reject! do |value|
|
153
|
+
if @row_keys[associated_model].has_key?(key) &&
|
154
|
+
@row_keys[associated_model][key].has_key?(value)
|
155
|
+
existing_records << @row_keys[associated_model][key][value]
|
156
|
+
else
|
157
|
+
false
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
new_query_class = @connection_manager.repository(associated_model.repository_name)[:query]
|
162
|
+
new_query = new_query_class.new(self,associated_model, &block)
|
163
|
+
|
164
|
+
new_records = new_query.multi_get(key, values).collect do |row|
|
165
|
+
m = spec[:model].new(row)
|
166
|
+
m._query = new_query
|
167
|
+
m
|
168
|
+
end
|
169
|
+
|
170
|
+
self.queries[associated_model].add(new_query)
|
171
|
+
|
172
|
+
add_records(new_records)
|
173
|
+
|
174
|
+
subload(associated_model, new_query, new_records + existing_records)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def finalize!(*models)
|
179
|
+
if models.empty?
|
180
|
+
models = @staged.keys
|
181
|
+
end
|
182
|
+
|
183
|
+
did_something = models.any? do |model|
|
184
|
+
finalize_model!(model).any?
|
185
|
+
end
|
186
|
+
|
187
|
+
finalize! if did_something
|
188
|
+
end
|
189
|
+
|
190
|
+
|
191
|
+
# don't doc. never ever use yourself!
|
192
|
+
# FIXME: make private and fix specs that break?
|
193
|
+
def finalize_model!(model, query=nil)
|
194
|
+
staged_queries = @staged[model].delete(:_queries) || []
|
195
|
+
staged_keys = @staged[model].keys
|
196
|
+
identities = staged_keys && model.identities
|
197
|
+
non_identities = staged_keys - model.identities
|
198
|
+
|
199
|
+
results = Set.new
|
200
|
+
|
201
|
+
return results if @staged[model].all? { |(key,values)| values.empty? }
|
202
|
+
|
203
|
+
if query.nil?
|
204
|
+
query_class = @connection_manager.repository(model.repository_name)[:query]
|
205
|
+
query = query_class.new(self,model)
|
206
|
+
end
|
207
|
+
|
208
|
+
# Apply any relevant blocks passed to track in the original queries
|
209
|
+
staged_queries.each do |staged_query|
|
210
|
+
staged_query.track.each do |(association_name, block)|
|
211
|
+
next unless block
|
212
|
+
|
213
|
+
spec = staged_query.model.associations[association_name]
|
214
|
+
|
215
|
+
if spec[:model] == model
|
216
|
+
query.instance_eval(&block)
|
217
|
+
if (spec[:type] == :many_to_one || spec[:type] == :array_to_many) && query.where
|
218
|
+
file, line = block.source_location
|
219
|
+
trace = ["#{file}:#{line}"] | caller
|
220
|
+
raise RuntimeError, "Error finalizing model #{model.name} for association #{association_name.inspect} -- using a where clause when tracking associations of type #{spec[:type].inspect} is not supported", trace
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
|
227
|
+
# process non-unique staged keys
|
228
|
+
# select identity (any one should do) for those keys and stage blindly
|
229
|
+
# load and add records.
|
230
|
+
|
231
|
+
if non_identities.any?
|
232
|
+
to_stage = Hash.new do |hash,identity|
|
233
|
+
hash[identity] = Set.new
|
234
|
+
end
|
235
|
+
|
236
|
+
non_identities.each do |key|
|
237
|
+
values = @staged[model].delete(key)
|
238
|
+
|
239
|
+
rows = query.multi_get(key, values, select: model.identities)
|
240
|
+
rows.each do |row|
|
241
|
+
model.identities.each do |identity|
|
242
|
+
if identity.kind_of? Array
|
243
|
+
to_stage[identity] << row.values_at(*identity)
|
244
|
+
else
|
245
|
+
to_stage[identity] << row[identity]
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
self.stage(model, to_stage)
|
252
|
+
end
|
253
|
+
|
254
|
+
model.identities.each do |identity_name|
|
255
|
+
values = self.get_staged(model,identity_name)
|
256
|
+
next if values.empty?
|
257
|
+
|
258
|
+
query.where = nil # clear out any where clause from non-identity
|
259
|
+
records = query.multi_get(identity_name, values).collect do |row|
|
260
|
+
m = model.new(row)
|
261
|
+
m._query = query
|
262
|
+
m
|
263
|
+
end
|
264
|
+
|
265
|
+
add_records(records)
|
266
|
+
|
267
|
+
# TODO: refactor this to better-hide queries?
|
268
|
+
self.queries[model].add(query)
|
269
|
+
|
270
|
+
results.merge(records)
|
271
|
+
|
272
|
+
# add nil records for records that were not found by the multi_get
|
273
|
+
missing_keys = self.get_staged(model,identity_name)
|
274
|
+
missing_keys.each do |missing_key|
|
275
|
+
@row_keys[model][identity_name][missing_key] = nil
|
276
|
+
get_staged(model, identity_name).delete(missing_key)
|
277
|
+
end
|
278
|
+
|
279
|
+
end
|
280
|
+
|
281
|
+
query.freeze
|
282
|
+
|
283
|
+
# TODO: check whether really really did get all the records we should have....
|
284
|
+
results.to_a
|
285
|
+
end
|
286
|
+
|
287
|
+
|
288
|
+
def row_by_key(model,key, value)
|
289
|
+
@row_keys[model][key].fetch(value) do
|
290
|
+
raise UnloadedRecordException, "Did not load #{model} with #{key} = #{value.inspect}."
|
291
|
+
end
|
292
|
+
|
293
|
+
end
|
294
|
+
|
295
|
+
|
296
|
+
def rows_for(model)
|
297
|
+
@rows[model]
|
298
|
+
end
|
299
|
+
|
300
|
+
|
301
|
+
def index(model, key, value)
|
302
|
+
@secondary_indexes[model] ||= Hash.new
|
303
|
+
|
304
|
+
unless @secondary_indexes[model].has_key? key
|
305
|
+
@secondary_indexes[model][key] ||= Hash.new
|
306
|
+
reindex!(model, key)
|
307
|
+
end
|
308
|
+
|
309
|
+
@secondary_indexes[model][key][value] ||= Array.new
|
310
|
+
end
|
311
|
+
|
312
|
+
|
313
|
+
def reindex!(model, key)
|
314
|
+
rows_for(model).each do |row|
|
315
|
+
val = if key.kind_of? Array
|
316
|
+
key.collect { |k| row.send(k) }
|
317
|
+
else
|
318
|
+
row.send(key)
|
319
|
+
end
|
320
|
+
index(model, key, val) << row
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
|
325
|
+
def all(model,conditions={})
|
326
|
+
return rows_for(model) if conditions.empty?
|
327
|
+
|
328
|
+
key, values = conditions.first
|
329
|
+
|
330
|
+
# optimize the common case of a single value
|
331
|
+
if values.size == 1
|
332
|
+
value = values[0]
|
333
|
+
if @row_keys[model].has_key?(key)
|
334
|
+
res = row_by_key(model, key, value)
|
335
|
+
if res
|
336
|
+
[row_by_key(model, key, value)]
|
337
|
+
else
|
338
|
+
[]
|
339
|
+
end
|
340
|
+
else
|
341
|
+
index(model, key, value)
|
342
|
+
end
|
343
|
+
else
|
344
|
+
if @row_keys[model].has_key?(key)
|
345
|
+
values.collect do |value|
|
346
|
+
row_by_key(model, key, value)
|
347
|
+
end
|
348
|
+
else
|
349
|
+
values.each_with_object(Array.new) do |value, results|
|
350
|
+
results.push *index(model, key, value)
|
351
|
+
end
|
352
|
+
end
|
353
|
+
end
|
354
|
+
end
|
355
|
+
|
356
|
+
|
357
|
+
def get(model,condition)
|
358
|
+
key, value = condition.first
|
359
|
+
|
360
|
+
row_by_key(model, key, value)
|
361
|
+
end
|
362
|
+
|
363
|
+
|
364
|
+
def get_staged(model, key)
|
365
|
+
@staged[model][key]
|
366
|
+
end
|
367
|
+
|
368
|
+
|
369
|
+
def stage(model, data)
|
370
|
+
data.each do |key, values|
|
371
|
+
unless values.kind_of? Enumerable
|
372
|
+
values = [values]
|
373
|
+
end
|
374
|
+
|
375
|
+
# ignore rows we have already loaded... add sanity checking?
|
376
|
+
if model.identities.include?(key)
|
377
|
+
values.reject! { |k| @row_keys[model][key].has_key? k }
|
378
|
+
end
|
379
|
+
|
380
|
+
get_staged(model,key).merge(values)
|
381
|
+
end
|
382
|
+
end
|
383
|
+
|
384
|
+
|
385
|
+
def connection(name)
|
386
|
+
@connection_manager.checkout(name)
|
387
|
+
end
|
388
|
+
|
389
|
+
|
390
|
+
def <<(record)
|
391
|
+
model = record.class
|
392
|
+
|
393
|
+
@rows[model] << record
|
394
|
+
record.identity_map = self
|
395
|
+
|
396
|
+
model.identities.each do |identity|
|
397
|
+
key = record.send(identity)
|
398
|
+
|
399
|
+
get_staged(model, identity).delete(key)
|
400
|
+
@row_keys[model][identity][key] = record
|
401
|
+
end
|
402
|
+
end
|
403
|
+
|
404
|
+
|
405
|
+
def extract_keys(field, records)
|
406
|
+
row_keys = []
|
407
|
+
if field.kind_of?(Array) # composite identities
|
408
|
+
records.each do |record|
|
409
|
+
row_key = field.collect { |col| record.send(col) }
|
410
|
+
row_keys << row_key unless row_key.include?(nil)
|
411
|
+
end
|
412
|
+
else
|
413
|
+
row_keys.push *records.collect(&field).compact
|
414
|
+
end
|
415
|
+
row_keys
|
416
|
+
end
|
417
|
+
|
418
|
+
|
419
|
+
def stage_many_to_one(tracked_association, records)
|
420
|
+
key = tracked_association[:key]
|
421
|
+
primary_key = tracked_association[:primary_key] || :id
|
422
|
+
|
423
|
+
row_keys = extract_keys(key, records)
|
424
|
+
|
425
|
+
[primary_key, row_keys]
|
426
|
+
end
|
427
|
+
|
428
|
+
|
429
|
+
def stage_one_to_many(tracked_association, records)
|
430
|
+
key = tracked_association[:key]
|
431
|
+
primary_key = tracked_association[:primary_key] || :id
|
432
|
+
|
433
|
+
row_keys = extract_keys(primary_key, records)
|
434
|
+
|
435
|
+
[key, row_keys]
|
436
|
+
end
|
437
|
+
|
438
|
+
|
439
|
+
def stage_array_to_many(tracked_association, records)
|
440
|
+
key = tracked_association[:key]
|
441
|
+
primary_key = tracked_association[:primary_key] || :id
|
442
|
+
|
443
|
+
row_keys = []
|
444
|
+
records.collect(&key).each do |keys|
|
445
|
+
row_keys.push *keys
|
446
|
+
end
|
447
|
+
|
448
|
+
row_keys.reject! do |row_key|
|
449
|
+
row_key.nil? || (row_key.kind_of?(Array) && row_key.include?(nil))
|
450
|
+
end
|
451
|
+
|
452
|
+
[primary_key, row_keys]
|
453
|
+
end
|
454
|
+
|
455
|
+
|
456
|
+
|
457
|
+
def stage_many_to_array(tracked_association, records)
|
458
|
+
raise "not supported yet"
|
459
|
+
end
|
460
|
+
|
461
|
+
|
462
|
+
def add_records(records)
|
463
|
+
return if records.empty?
|
464
|
+
|
465
|
+
records_added = Array.new
|
466
|
+
|
467
|
+
to_stage = Hash.new do |hash,staged_model|
|
468
|
+
hash[staged_model] = Hash.new do |identities, identity_name|
|
469
|
+
identities[identity_name] = Set.new
|
470
|
+
end
|
471
|
+
end
|
472
|
+
|
473
|
+
model = records.first.class
|
474
|
+
|
475
|
+
tracked_associations = if (query = records.first._query)
|
476
|
+
query.tracked_associations.each do |tracked_association|
|
477
|
+
associated_model = tracked_association[:model]
|
478
|
+
to_stage[associated_model][:_queries] << query
|
479
|
+
end
|
480
|
+
else
|
481
|
+
[]
|
482
|
+
end
|
483
|
+
|
484
|
+
tracked_associations.each do |tracked_association|
|
485
|
+
associated_model = tracked_association[:model]
|
486
|
+
association_type = tracked_association[:type]
|
487
|
+
|
488
|
+
association_key, row_keys = stage_for!(tracked_association, records)
|
489
|
+
row_keys.each do |row_key|
|
490
|
+
to_stage[associated_model][association_key].add(row_key)
|
491
|
+
end
|
492
|
+
|
493
|
+
end
|
494
|
+
|
495
|
+
records.each do |record|
|
496
|
+
if add_record(record)
|
497
|
+
records_added << record
|
498
|
+
end
|
499
|
+
end
|
500
|
+
|
501
|
+
to_stage.each do |model_to_stage, data|
|
502
|
+
stage(model_to_stage, data)
|
503
|
+
end
|
504
|
+
|
505
|
+
records_added
|
506
|
+
end
|
507
|
+
|
508
|
+
|
509
|
+
def add_record(record)
|
510
|
+
model = record.class
|
511
|
+
record.identities.each do |identity, key|
|
512
|
+
# FIXME: Should we be overwriting (possibly) a "nil" value from before?
|
513
|
+
# (due to that row not being found by a previous query)
|
514
|
+
# (That'd be odd since that means we tried to load that same identity)
|
515
|
+
|
516
|
+
return false if @row_keys[model][identity].has_key? key
|
517
|
+
|
518
|
+
get_staged(model, identity).delete(key)
|
519
|
+
@row_keys[model][identity][key] = record
|
520
|
+
end
|
521
|
+
|
522
|
+
record.identity_map = self
|
523
|
+
@rows[model] << record
|
524
|
+
record
|
525
|
+
end
|
526
|
+
|
527
|
+
def query_statistics
|
528
|
+
QueryStatistics.new(queries)
|
529
|
+
end
|
530
|
+
|
531
|
+
end
|
532
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Praxis::Mapper
|
2
|
+
|
3
|
+
class NullLogger
|
4
|
+
|
5
|
+
# do nothing!
|
6
|
+
def initialize(*args)
|
7
|
+
end
|
8
|
+
|
9
|
+
def method_missing(*args, &block)
|
10
|
+
end
|
11
|
+
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.logger
|
15
|
+
@@logger ||= NullLogger.new
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.logger=(logger)
|
19
|
+
@@logger = logger
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|