cell_set 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README +5 -0
- data/Rakefile +1 -0
- data/lib/cell_set/attributes.rb +62 -0
- data/lib/cell_set/cell.rb +24 -0
- data/lib/cell_set/cell_set.rb +152 -0
- data/lib/cell_set/cell_set_axis.rb +79 -0
- data/lib/cell_set/member.rb +75 -0
- data/lib/cell_set/model.rb +5 -0
- data/lib/cell_set/position.rb +60 -0
- data/lib/cell_set/result.rb +9 -0
- data/lib/cell_set/version.rb +3 -0
- data/lib/cell_set.rb +25 -0
- data/lib/mondrian/olap/cell_set/ruby.rb +61 -0
- data/spec/factories/cell_set_axis_factory.rb +4 -0
- data/spec/factories/cell_set_factory.rb +3 -0
- data/spec/factories/member_factory.rb +5 -0
- data/spec/factories/position_factory.rb +4 -0
- data/spec/factories/sequences.rb +7 -0
- data/spec/fixtures/FoodMart.xml +778 -0
- data/spec/fixtures/database.yml +19 -0
- data/spec/fixtures/postgresql-9.0-801.jdbc4.jar +0 -0
- data/spec/models/cell_set_axis_spec.rb +86 -0
- data/spec/models/cell_set_spec.rb +160 -0
- data/spec/models/member_spec.rb +68 -0
- data/spec/models/mondrian/olap/mondrian_olap4j_cell_set_spec.rb +109 -0
- data/spec/models/mondrian/olap/result_spec.rb +17 -0
- data/spec/models/position_spec.rb +83 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/support/matchers/have_accessor.rb +7 -0
- metadata +166 -0
data/README
ADDED
@@ -0,0 +1,5 @@
|
|
1
|
+
cell_set is a ruby implementation of the CellSet and related models from mondrian.olap4j.
|
2
|
+
|
3
|
+
The cell_set gem automatically mixes in a to_ruby method on Mondrian::OLAP::CellSet that can be called from the instance variable @raw_cell_set in an instance of Mondrian::OLAP::Result.
|
4
|
+
|
5
|
+
Normally the to_ruby method is automatically called in the to_cell_set method in an instance of Mondrian::OLAP::Result.
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# Use this module to add necessary attributes methods that ActiveModel::AttributeMethods doesn't add
|
2
|
+
# An ATTRIBUTES constant must be on the model before including this module
|
3
|
+
# Attributes specified in ATTRIBUTES will be serialized if any ActiveModel::Serializers are used
|
4
|
+
# Any attributes that shouldn't be serialized can still be set with another attr_accessor, but won't show up in
|
5
|
+
#
|
6
|
+
|
7
|
+
module CellSet
|
8
|
+
module Attributes
|
9
|
+
extend ActiveSupport::Concern
|
10
|
+
extend ActiveModel::Naming
|
11
|
+
include ActiveModel::AttributeMethods
|
12
|
+
|
13
|
+
included do
|
14
|
+
self.send(:attr_accessor, *self::ATTRIBUTES)
|
15
|
+
|
16
|
+
if self.private_methods.include?(:define_attribute_method)
|
17
|
+
self.send(:define_attribute_method, self::ATTRIBUTES)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
module ClassMethods
|
22
|
+
def human_attribute_name(attr, options = {})
|
23
|
+
attr
|
24
|
+
end
|
25
|
+
|
26
|
+
def lookup_ancestors
|
27
|
+
[self]
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def initialize(attrs = {})
|
32
|
+
@errors = ActiveModel::Errors.new(self)
|
33
|
+
attrs.each do |name, value|
|
34
|
+
send("#{name}=", value) if value
|
35
|
+
end
|
36
|
+
super(attrs)
|
37
|
+
end
|
38
|
+
|
39
|
+
def attributes
|
40
|
+
self.class::ATTRIBUTES.inject(ActiveSupport::HashWithIndifferentAccess.new) do |result, key|
|
41
|
+
result[key] = read_attribute_for_validation(key)
|
42
|
+
result
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def attributes=(attrs)
|
47
|
+
attrs.each_pair {|k, v| send("#{k}=", v)}
|
48
|
+
end
|
49
|
+
|
50
|
+
def clear_attribute(attr)
|
51
|
+
send("#{attr}=", nil)
|
52
|
+
end
|
53
|
+
|
54
|
+
def has_attribute?(attr)
|
55
|
+
self.class::ATTRIBUTES.include?(attr)
|
56
|
+
end
|
57
|
+
|
58
|
+
def read_attribute_for_validation(key)
|
59
|
+
send(key)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module CellSet
|
2
|
+
class Cell
|
3
|
+
ATTRIBUTES = [:formatted_value, :ordinal, :value]
|
4
|
+
|
5
|
+
include Attributes
|
6
|
+
include ActiveModel::Serializers::JSON
|
7
|
+
include ActiveModel::Serializers::Xml
|
8
|
+
self.include_root_in_json = false
|
9
|
+
|
10
|
+
def from_json(*)
|
11
|
+
super.tap{|obj| obj.freeze}
|
12
|
+
end
|
13
|
+
|
14
|
+
def ordinal=(ordinal)
|
15
|
+
@ordinal = if ordinal.is_a?(Fixnum)
|
16
|
+
ordinal
|
17
|
+
elsif ordinal.is_a?(String)
|
18
|
+
Integer(ordinal)
|
19
|
+
else
|
20
|
+
throw ArgumentError
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
require 'cell_set/cell_set_axis'
|
2
|
+
require 'cell_set/cell'
|
3
|
+
|
4
|
+
module CellSet
|
5
|
+
class CellSet
|
6
|
+
ATTRIBUTES = [:axes]
|
7
|
+
|
8
|
+
include Attributes
|
9
|
+
include ActiveModel::Serializers::JSON
|
10
|
+
include ActiveModel::Serializers::Xml
|
11
|
+
|
12
|
+
def as_json(options = nil)
|
13
|
+
# Strangely couldn't use options = {} in JRuby
|
14
|
+
options = {} if options.nil?
|
15
|
+
json = super(options)
|
16
|
+
unless (options.has_key?(:only) && !options[:only].include?("cells")) ||
|
17
|
+
(options.has_key?(:except) && options[:except].include?("cells"))
|
18
|
+
json.first[1]["cells"] = @cellHash
|
19
|
+
end
|
20
|
+
json
|
21
|
+
end
|
22
|
+
|
23
|
+
def axes=(axes)
|
24
|
+
if axes.is_a?(Array)
|
25
|
+
@axes = axes.map do |axis|
|
26
|
+
if axis.is_a?(CellSetAxis)
|
27
|
+
axis.cell_set = self
|
28
|
+
axis
|
29
|
+
elsif axis.is_a?(Hash)
|
30
|
+
CellSetAxis.new(:cell_set => self).from_json(axis.to_json)
|
31
|
+
else
|
32
|
+
throw ArgumentError
|
33
|
+
end
|
34
|
+
end
|
35
|
+
else
|
36
|
+
throw ArgumentError
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def bounds
|
41
|
+
@axes.map{|axis| axis.positionCount}
|
42
|
+
end
|
43
|
+
|
44
|
+
def cell(*args)
|
45
|
+
if args[0].is_a?(NilClass)
|
46
|
+
throw(ArgumentError)
|
47
|
+
elsif args[0].is_a?(Fixnum) && args.length == 1
|
48
|
+
getCellInternal(args[0])
|
49
|
+
elsif args[0].is_a?(Array) && args.length == 1
|
50
|
+
getCellInternal(coordinatesToOrdinal(args[0]))
|
51
|
+
elsif args.select{|arg| arg.is_a?(Position)}.length == args.length
|
52
|
+
unless args.length == axes.length
|
53
|
+
throw ArgumentError, "cell coordinates should have dimension #{axis.length}"
|
54
|
+
end
|
55
|
+
cell(args.map{|position| position.getOrdinal})
|
56
|
+
else
|
57
|
+
throw(ArgumentError)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def cells=(cells)
|
62
|
+
if cells.is_a?(Array)
|
63
|
+
cells.each do |cell|
|
64
|
+
if cell.is_a?(Cell)
|
65
|
+
@cellHash[cell.ordinal.to_s] = {
|
66
|
+
"formatted_value" => cell.formatted_value,
|
67
|
+
"value" => cell.value
|
68
|
+
}
|
69
|
+
else
|
70
|
+
throw ArgumentError
|
71
|
+
end
|
72
|
+
end
|
73
|
+
elsif cells.is_a?(Hash)
|
74
|
+
@cellHash = cells
|
75
|
+
else
|
76
|
+
throw ArgumentError
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def coordinatesToOrdinal(coordinates)
|
81
|
+
unless coordinates.length == @axes.length
|
82
|
+
throw ArgumentError, "Coordinates have different dimension #{coordinates.length} than axes #{@axes.length}"
|
83
|
+
end
|
84
|
+
|
85
|
+
modulo = 1
|
86
|
+
ordinal = 0
|
87
|
+
k = 0
|
88
|
+
@axes.each do |axis|
|
89
|
+
coordinate = coordinates[k]
|
90
|
+
if coordinate < 0 || coordinate >= axis.positionCount
|
91
|
+
throw IndexError, "Coordinate #{coordinate} of axis #{k} is out of range (#{bounds.join(", ")})"
|
92
|
+
end
|
93
|
+
ordinal += coordinate * modulo
|
94
|
+
modulo *= axis.positionCount
|
95
|
+
end
|
96
|
+
ordinal
|
97
|
+
end
|
98
|
+
|
99
|
+
def from_json(*)
|
100
|
+
super.tap{|obj| obj.freeze}
|
101
|
+
end
|
102
|
+
|
103
|
+
def getFilterAxis
|
104
|
+
throw NotImplementedError
|
105
|
+
end
|
106
|
+
|
107
|
+
def initialize(*)
|
108
|
+
@cellHash = {}
|
109
|
+
super
|
110
|
+
end
|
111
|
+
|
112
|
+
def maxOrdinal
|
113
|
+
modulo = 1
|
114
|
+
@axes.each do |axis|
|
115
|
+
modulo *= axis.positionCount
|
116
|
+
end
|
117
|
+
modulo
|
118
|
+
end
|
119
|
+
|
120
|
+
def ordinalToCoordinates(ordinal)
|
121
|
+
modulo = 1
|
122
|
+
|
123
|
+
list = @axes.map do |axis|
|
124
|
+
prevModulo = modulo
|
125
|
+
modulo *= axis.positionCount
|
126
|
+
(ordinal % modulo) / prevModulo
|
127
|
+
end
|
128
|
+
|
129
|
+
if ordinal < 0 || ordinal >= modulo
|
130
|
+
throw IndexError, "Cell ordinal #{ordinal} lies outside CellSet bounds (#{bounds.join(", ")})"
|
131
|
+
end
|
132
|
+
|
133
|
+
list
|
134
|
+
end
|
135
|
+
|
136
|
+
private
|
137
|
+
attr_accessor :cellHash
|
138
|
+
|
139
|
+
def getCellInternal(pos)
|
140
|
+
cell = @cellHash[pos.to_s]
|
141
|
+
if cell.nil?
|
142
|
+
if (pos < 0 || pos >= maxOrdinal)
|
143
|
+
throw IndexError
|
144
|
+
else
|
145
|
+
Cell.new(:formatted_value => "", :ordinal => pos)
|
146
|
+
end
|
147
|
+
else
|
148
|
+
Cell.new(:formatted_value => cell["formatted_value"], :ordinal => pos, :value => cell["value"])
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
require 'cell_set/position'
|
2
|
+
|
3
|
+
module CellSet
|
4
|
+
class CellSetAxis
|
5
|
+
ATTRIBUTES = [:axis_ordinal, :cell_set, :positions]
|
6
|
+
|
7
|
+
include Attributes
|
8
|
+
include ActiveModel::Serializers::JSON
|
9
|
+
include ActiveModel::Serializers::Xml
|
10
|
+
include Comparable
|
11
|
+
self.include_root_in_json = false
|
12
|
+
|
13
|
+
def <=>(other)
|
14
|
+
if other.is_a?(self.class)
|
15
|
+
@axis_ordinal <=> other.axis_ordinal
|
16
|
+
else
|
17
|
+
raise(ArgumentError, "Must compare #{self.class.to_s} to another #{self.class.to_s}")
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def axis_ordinal=(ordinal)
|
22
|
+
@axis_ordinal = if ordinal.is_a?(Fixnum)
|
23
|
+
ordinal
|
24
|
+
elsif ordinal.is_a?(String)
|
25
|
+
Integer(ordinal)
|
26
|
+
else
|
27
|
+
throw ArgumentError
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def between?(min, max)
|
32
|
+
@axis_ordinal <= min || @axis_ordinal >= max
|
33
|
+
end
|
34
|
+
|
35
|
+
def cell_set=(cell_set)
|
36
|
+
@cell_set = if cell_set.is_a?(CellSet)
|
37
|
+
cell_set
|
38
|
+
elsif cell_set.is_a?(Hash)
|
39
|
+
CellSet.new.from_json(cell_set.to_json)
|
40
|
+
else
|
41
|
+
throw ArgumentError
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def from_json(*)
|
46
|
+
super.tap{|obj| obj.freeze}
|
47
|
+
end
|
48
|
+
|
49
|
+
def positionCount
|
50
|
+
@positions.length
|
51
|
+
end
|
52
|
+
|
53
|
+
def positions=(positions)
|
54
|
+
if positions.is_a?(Array)
|
55
|
+
@positions = positions.map do |position|
|
56
|
+
if position.is_a?(Position)
|
57
|
+
position
|
58
|
+
elsif position.is_a?(Hash)
|
59
|
+
Position.new.from_json(position.to_json)
|
60
|
+
else
|
61
|
+
throw ArgumentError
|
62
|
+
end
|
63
|
+
end
|
64
|
+
else
|
65
|
+
throw ArgumentError
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def as_json(options = nil)
|
70
|
+
# Strangely couldn't use options = {} in JRuby
|
71
|
+
options = {} if options.nil?
|
72
|
+
unless (options.has_key?(:only) && options[:only].include?("cell_set")) ||
|
73
|
+
(options.has_key?(:except) && options[:except].include?("cell_set"))
|
74
|
+
options[:except] = (options[:except] || []).push("cell_set")
|
75
|
+
end
|
76
|
+
super(options)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module CellSet
|
2
|
+
class Member
|
3
|
+
ATTRIBUTES = [:caption, :childMembers, :depth, :description, :dimension, :expression, :hierarchy, :level, :memberType, \
|
4
|
+
:name, :ordinal, :parentMember, :uniqueName]
|
5
|
+
|
6
|
+
include Attributes
|
7
|
+
include ActiveModel::Serializers::JSON
|
8
|
+
include ActiveModel::Serializers::Xml
|
9
|
+
include Comparable
|
10
|
+
self.include_root_in_json = false
|
11
|
+
|
12
|
+
def <=>(other)
|
13
|
+
if other.is_a?(self.class)
|
14
|
+
@ordinal <=> other.ordinal
|
15
|
+
else
|
16
|
+
nil
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def ancestorMembers
|
21
|
+
throw NotImplementedError
|
22
|
+
end
|
23
|
+
|
24
|
+
def between?(min, max)
|
25
|
+
@ordinal <= min || @ordinal >= max
|
26
|
+
end
|
27
|
+
|
28
|
+
def childMemberCount
|
29
|
+
throw NotImplementedError
|
30
|
+
end
|
31
|
+
|
32
|
+
def all?
|
33
|
+
throw NotImplementedError
|
34
|
+
end
|
35
|
+
|
36
|
+
def calculated?
|
37
|
+
throw NotImplementedError
|
38
|
+
end
|
39
|
+
|
40
|
+
def calculatedInQuery?
|
41
|
+
throw NotImplementedError
|
42
|
+
end
|
43
|
+
|
44
|
+
def childOrEqualTo?(member)
|
45
|
+
throw NotImplementedError
|
46
|
+
end
|
47
|
+
|
48
|
+
def from_json(*)
|
49
|
+
super.tap{|obj| obj.freeze}
|
50
|
+
end
|
51
|
+
|
52
|
+
def hidden?
|
53
|
+
throw NotImplementedError
|
54
|
+
end
|
55
|
+
|
56
|
+
def initialize(*)
|
57
|
+
@childMembers = []
|
58
|
+
super
|
59
|
+
end
|
60
|
+
|
61
|
+
def ordinal=(ordinal)
|
62
|
+
@ordinal = if ordinal.is_a?(Fixnum)
|
63
|
+
ordinal
|
64
|
+
elsif ordinal.is_a?(String)
|
65
|
+
Integer(ordinal)
|
66
|
+
else
|
67
|
+
throw ArgumentError
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def visible?
|
72
|
+
throw NotImplementedError
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require 'cell_set/member'
|
2
|
+
|
3
|
+
module CellSet
|
4
|
+
class Position
|
5
|
+
ATTRIBUTES = [:members, :ordinal]
|
6
|
+
|
7
|
+
include Attributes
|
8
|
+
include ActiveModel::Serializers::JSON
|
9
|
+
include ActiveModel::Serializers::Xml
|
10
|
+
include Comparable
|
11
|
+
self.include_root_in_json = false
|
12
|
+
|
13
|
+
def <=>(other)
|
14
|
+
if other.is_a?(self.class)
|
15
|
+
@ordinal <=> other.ordinal
|
16
|
+
else
|
17
|
+
nil
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def between?(min, max)
|
22
|
+
@ordinal <= min || @ordinal >= max
|
23
|
+
end
|
24
|
+
|
25
|
+
def from_json(*)
|
26
|
+
super.tap{|obj| obj.freeze}
|
27
|
+
end
|
28
|
+
|
29
|
+
def initialize(*)
|
30
|
+
@members = []
|
31
|
+
super
|
32
|
+
end
|
33
|
+
|
34
|
+
def members=(members)
|
35
|
+
if members.is_a?(Array)
|
36
|
+
@members = members.map do |member|
|
37
|
+
if member.is_a?(Member)
|
38
|
+
member
|
39
|
+
elsif member.is_a?(Hash)
|
40
|
+
Member.new.from_json(member.to_json)
|
41
|
+
else
|
42
|
+
throw ArgumentError
|
43
|
+
end
|
44
|
+
end
|
45
|
+
else
|
46
|
+
throw ArgumentError
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def ordinal=(ordinal)
|
51
|
+
@ordinal = if ordinal.is_a?(Fixnum)
|
52
|
+
ordinal
|
53
|
+
elsif ordinal.is_a?(String)
|
54
|
+
Integer(ordinal)
|
55
|
+
else
|
56
|
+
throw ArgumentError
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
data/lib/cell_set.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
require 'active_support/hash_with_indifferent_access'
|
3
|
+
require 'active_model/attribute_methods'
|
4
|
+
require 'active_model/naming'
|
5
|
+
require 'active_model/serialization'
|
6
|
+
require 'active_model/serializers/json'
|
7
|
+
require 'active_model/serializers/xml'
|
8
|
+
if RUBY_PLATFORM == "java"
|
9
|
+
require 'mondrian-olap'
|
10
|
+
end
|
11
|
+
|
12
|
+
%w(version attributes result cell member position cell_set_axis cell_set model).each do |file|
|
13
|
+
require "cell_set/#{file}"
|
14
|
+
end
|
15
|
+
|
16
|
+
%w(ruby).each do |file|
|
17
|
+
require "mondrian/olap/cell_set/#{file}"
|
18
|
+
end
|
19
|
+
|
20
|
+
if RUBY_PLATFORM == "java"
|
21
|
+
ActiveSupport.on_load(:cell_set_model) do
|
22
|
+
Java::Mondrian::olap4j::MondrianOlap4jCellSet.send(:include, Mondrian::OLAP::CellSet::Ruby)
|
23
|
+
Mondrian::OLAP::Result.send(:include, ::CellSet::Result)
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module Mondrian
|
2
|
+
module OLAP
|
3
|
+
module CellSet
|
4
|
+
module Ruby
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
def to_ruby
|
8
|
+
cell_set = ::CellSet::CellSet.new
|
9
|
+
axes = getAxes.map do |axis|
|
10
|
+
::CellSet::CellSetAxis.new(:axis_ordinal => axis.getAxisOrdinal.axisOrdinal, :cell_set => cell_set, :positions => buildPositions(axis))
|
11
|
+
end
|
12
|
+
cell_set.axes = axes
|
13
|
+
cell_set.send("cellHash=", getCellHash)
|
14
|
+
cell_set.freeze
|
15
|
+
cell_set
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
def buildPositions(axis)
|
20
|
+
axis.positions.map do |position|
|
21
|
+
members = position.getMembers.map do |member|
|
22
|
+
::CellSet::Member.new(
|
23
|
+
:caption => member.getCaption,
|
24
|
+
:depth => member.getDepth,
|
25
|
+
:description => member.getDescription,
|
26
|
+
:memberType => member.getMemberType.to_s,
|
27
|
+
:name => member.getName,
|
28
|
+
:ordinal => member.getOrdinal,
|
29
|
+
:uniqueName => member.getUniqueName
|
30
|
+
)
|
31
|
+
end
|
32
|
+
::CellSet::Position.new(:members => members, :ordinal => position.getOrdinal)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def getCellHash
|
37
|
+
cells = {}
|
38
|
+
maxOrdinal.times do |i|
|
39
|
+
formatted_value, value = *getCell(i).try{|cell| [cell.getFormattedValue, cell.getValue]}
|
40
|
+
unless formatted_value.blank? && value.nil?
|
41
|
+
# Build key as string
|
42
|
+
cells[i.to_s] = {
|
43
|
+
"formatted_value" => formatted_value,
|
44
|
+
"value" => value
|
45
|
+
}
|
46
|
+
end
|
47
|
+
end
|
48
|
+
cells
|
49
|
+
end
|
50
|
+
|
51
|
+
def maxOrdinal
|
52
|
+
modulo = 1
|
53
|
+
getAxes.each do |axis|
|
54
|
+
modulo *= axis.getPositionCount
|
55
|
+
end
|
56
|
+
modulo
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|