flydata 0.4.0 → 0.4.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 +4 -4
- data/Gemfile +1 -1
- data/Gemfile.lock +2 -2
- data/VERSION +1 -1
- data/flydata-core/lib/flydata-core/errors.rb +12 -0
- data/{lib/flydata/mysql/mysql_util.rb → flydata-core/lib/flydata-core/mysql/command_generator.rb} +39 -6
- data/flydata-core/lib/flydata-core/mysql/compatibility_checker.rb +222 -0
- data/flydata-core/lib/flydata-core/query_job.rb +7 -0
- data/flydata-core/lib/flydata-core/query_job/redshift.rb +27 -0
- data/{spec/flydata/mysql/mysql_util_spec.rb → flydata-core/spec/mysql/command_generator_spec.rb} +22 -3
- data/flydata-core/spec/mysql/compatibility_checker.rb +9 -0
- data/flydata-core/spec/query_job/redshift_spec.rb +34 -0
- data/flydata.gemspec +14 -9
- data/lib/flydata/api/data_entry.rb +9 -0
- data/lib/flydata/command/mysql.rb +2 -1
- data/lib/flydata/command/sync.rb +62 -31
- data/lib/flydata/compatibility_check.rb +33 -152
- data/lib/flydata/fluent-plugins/in_mysql_binlog_flydata.rb +4 -4
- data/lib/flydata/mysql/table_ddl.rb +2 -2
- data/lib/flydata/parser/mysql/dump_parser.rb +2 -2
- data/spec/flydata/command/sync_spec.rb +17 -15
- data/spec/flydata/compatibility_check_spec.rb +13 -12
- data/spec/flydata/mysql/table_ddl_spec.rb +1 -1
- metadata +12 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 83cb9df24d4fe0b983b20bfd30d1798aa4cbecdd
|
4
|
+
data.tar.gz: a00a24a84acf688553503bf97b156acdd57fc8ab
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 335e28547f000af29cf5df8d7a28632fe8fa5356298016f80819622c2a8299ae8469aa46a4e2ec8aa6179c472d151ea90583da1a7ab5f9367aec7668200c9ebc
|
7
|
+
data.tar.gz: 78049be459795e1eccb4b486c55e74989b8f2fae7cce9e5a8ce79236a12ad54d990550b30e71f9027e3ff299598d9f586a63fff8911ae159c6c0935f95e2bfeb
|
data/Gemfile
CHANGED
@@ -13,7 +13,7 @@ gem "slop", '~> 3.4', '>= 3.4.6'
|
|
13
13
|
gem "treetop", '~> 1.5', '>= 1.5.3'
|
14
14
|
gem "sys-filesystem", '~> 1.1', '>= 1.1.3'
|
15
15
|
gem "io-console", '~> 0.4.2', '>= 0.4.2'
|
16
|
-
gem "kodama", '~> 0.1.2', '>= 0.1.
|
16
|
+
gem "kodama", '~> 0.1.2', '>= 0.1.6'
|
17
17
|
gem "serverengine", '~> 1.5'
|
18
18
|
|
19
19
|
group :development do
|
data/Gemfile.lock
CHANGED
@@ -61,7 +61,7 @@ GEM
|
|
61
61
|
rdoc
|
62
62
|
json (1.8.1)
|
63
63
|
jwt (1.0.0)
|
64
|
-
kodama (0.1.
|
64
|
+
kodama (0.1.6)
|
65
65
|
ruby-binlog (~> 1.0, >= 1.0.4)
|
66
66
|
method_source (0.8.2)
|
67
67
|
mime-types (2.3)
|
@@ -135,7 +135,7 @@ DEPENDENCIES
|
|
135
135
|
io-console (~> 0.4.2, >= 0.4.2)
|
136
136
|
jeweler (~> 1.8, >= 1.8.8)
|
137
137
|
json (~> 1.8, >= 1.8.0)
|
138
|
-
kodama (~> 0.1.2, >= 0.1.
|
138
|
+
kodama (~> 0.1.2, >= 0.1.6)
|
139
139
|
mysql2 (~> 0.3, >= 0.3.17)
|
140
140
|
protected_attributes (~> 1.0, >= 1.0.8)
|
141
141
|
pry
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.4.
|
1
|
+
0.4.1
|
@@ -304,6 +304,18 @@ class TableDefError < StandardError
|
|
304
304
|
end
|
305
305
|
end
|
306
306
|
|
307
|
+
## Compatibility check error
|
308
|
+
|
309
|
+
class CompatibilityError < StandardError
|
310
|
+
end
|
311
|
+
|
312
|
+
class AgentCompatibilityError < StandardError
|
313
|
+
end
|
314
|
+
|
315
|
+
class MysqlCompatibilityError < StandardError
|
316
|
+
end
|
317
|
+
|
318
|
+
|
307
319
|
## Error container
|
308
320
|
|
309
321
|
class DataDeliveryErrorThreadContext
|
data/{lib/flydata/mysql/mysql_util.rb → flydata-core/lib/flydata-core/mysql/command_generator.rb}
RENAMED
@@ -1,9 +1,9 @@
|
|
1
1
|
require 'open3'
|
2
2
|
require 'flydata-core/table_def/mysql_table_def'
|
3
3
|
|
4
|
-
module
|
4
|
+
module FlydataCore
|
5
5
|
module Mysql
|
6
|
-
class
|
6
|
+
class CommandGenerator
|
7
7
|
DEFAULT_MYSQL_CMD_OPTION = "--default-character-set=utf8 --protocol=tcp"
|
8
8
|
|
9
9
|
# Generate mysql/mysqldump command with options
|
@@ -76,7 +76,34 @@ module Flydata
|
|
76
76
|
generate_mysql_cmd(opt)
|
77
77
|
end
|
78
78
|
|
79
|
-
def self.each_mysql_tabledef(tables,
|
79
|
+
def self.each_mysql_tabledef(tables, options, &block)
|
80
|
+
tables = tables.clone
|
81
|
+
missing_tables = []
|
82
|
+
begin
|
83
|
+
if tables.to_s == '' || tables.to_s == '[]'
|
84
|
+
raise ArgumentError, "tables is nil or empty"
|
85
|
+
end
|
86
|
+
_each_mysql_tabledef(tables, options, &block)
|
87
|
+
rescue TableMissingError => e
|
88
|
+
tables.delete e.table
|
89
|
+
missing_tables << e.table
|
90
|
+
return missing_tables if tables.empty?
|
91
|
+
retry
|
92
|
+
end
|
93
|
+
missing_tables
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
class TableMissingError < RuntimeError
|
99
|
+
def initialize(message, table)
|
100
|
+
super(message)
|
101
|
+
@table = table
|
102
|
+
end
|
103
|
+
attr_reader :table
|
104
|
+
end
|
105
|
+
|
106
|
+
def self._each_mysql_tabledef(tables, option)
|
80
107
|
command = generate_mysql_ddl_dump_cmd(option.merge(tables: tables))
|
81
108
|
|
82
109
|
create_opt = {}
|
@@ -100,14 +127,20 @@ module Flydata
|
|
100
127
|
errors = ""
|
101
128
|
while !stderr.eof?
|
102
129
|
line = stderr.gets.gsub('mysqldump: ', '')
|
103
|
-
|
130
|
+
case line
|
131
|
+
when /Couldn't find table: "([^"]+)"/
|
132
|
+
missing_table = $1
|
133
|
+
raise TableMissingError.new(line, missing_table)
|
134
|
+
when /Warning: Using a password on the command line interface can be insecure./
|
135
|
+
# Ignore
|
136
|
+
else
|
137
|
+
errors << line
|
138
|
+
end
|
104
139
|
end
|
105
140
|
raise errors unless errors.empty?
|
106
141
|
end
|
107
142
|
end
|
108
143
|
|
109
|
-
private
|
110
|
-
|
111
144
|
def self.convert_keys_to_sym(hash)
|
112
145
|
hash.inject(hash.dup) do |ret, (k, v)|
|
113
146
|
if k.kind_of?(String) && ret[k.to_sym].nil?
|
@@ -0,0 +1,222 @@
|
|
1
|
+
require 'flydata-core/errors'
|
2
|
+
require 'mysql2'
|
3
|
+
|
4
|
+
module FlydataCore
|
5
|
+
module Mysql
|
6
|
+
class CompatibilityChecker
|
7
|
+
def initialize(option = {})
|
8
|
+
@option = option || {}
|
9
|
+
end
|
10
|
+
|
11
|
+
def do_check(option = @option, &block)
|
12
|
+
result = block.call create_query(option)
|
13
|
+
check_result(result, option)
|
14
|
+
end
|
15
|
+
|
16
|
+
# Override
|
17
|
+
#def create_query(option = @option)
|
18
|
+
#end
|
19
|
+
|
20
|
+
# Override
|
21
|
+
#def validate_result(result, option = @option)
|
22
|
+
#end
|
23
|
+
end
|
24
|
+
|
25
|
+
class MysqlCompatibilityChecker < CompatibilityChecker
|
26
|
+
def do_check(option = @option, &block)
|
27
|
+
query = create_query(option)
|
28
|
+
result = if block
|
29
|
+
block.call query
|
30
|
+
else
|
31
|
+
exec_query(query)
|
32
|
+
end
|
33
|
+
check_result(result, option)
|
34
|
+
end
|
35
|
+
|
36
|
+
def exec_query(query)
|
37
|
+
begin
|
38
|
+
client = Mysql2::Client.new(@option)
|
39
|
+
client.query(query)
|
40
|
+
ensure
|
41
|
+
client.close rescue nil if client
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
class SyncPermissionChecker < MysqlCompatibilityChecker
|
47
|
+
def create_query(option = @option)
|
48
|
+
"SHOW GRANTS"
|
49
|
+
end
|
50
|
+
|
51
|
+
def check_result(result, option = @option)
|
52
|
+
databases = ['mysql', @option[:database]]
|
53
|
+
get_grant_regex = /GRANT (?<privs>.*) ON (`)?(?<db_name>[^`]*)(`)?\.\* TO '#{@option[:username]}/
|
54
|
+
necessary_permission_fields = ["SELECT","RELOAD","LOCK TABLES","REPLICATION SLAVE","REPLICATION CLIENT"]
|
55
|
+
all_privileges_field = ["ALL PRIVILEGES"]
|
56
|
+
|
57
|
+
found_priv = Hash[databases.map {|d| [d,[]]}]
|
58
|
+
missing_priv = {}
|
59
|
+
|
60
|
+
result.each do |res|
|
61
|
+
# SHOW GRANTS should only return one column
|
62
|
+
res_value = res.values.first
|
63
|
+
matched_values = res_value.match(get_grant_regex)
|
64
|
+
next unless matched_values
|
65
|
+
line_priv = matched_values["privs"].split(", ")
|
66
|
+
if matched_values["db_name"] == "*"
|
67
|
+
return true if (all_privileges_field - line_priv).empty?
|
68
|
+
databases.each {|d| found_priv[d] << line_priv }
|
69
|
+
elsif databases.include? matched_values["db_name"]
|
70
|
+
if (all_privileges_field - line_priv).empty?
|
71
|
+
found_priv[matched_values["db_name"]] = necessary_permission_fields
|
72
|
+
else
|
73
|
+
found_priv[matched_values["db_name"]] << line_priv
|
74
|
+
end
|
75
|
+
end
|
76
|
+
missing_priv = get_missing_privileges(found_priv, necessary_permission_fields)
|
77
|
+
return true if missing_priv.empty?
|
78
|
+
end
|
79
|
+
error_text = "The user '#{@option[:username]}' does not have the correct permissions to run FlyData Sync\n"
|
80
|
+
error_text << " * These privileges are missing...\n"
|
81
|
+
missing_priv.each_key {|db| error_text << " for the database '#{db}': #{missing_priv[db].join(", ")}\n"}
|
82
|
+
raise MysqlCompatibilityError, error_text
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
def get_missing_privileges(found_priv, all_priv)
|
88
|
+
return_hash = {}
|
89
|
+
found_priv.each_key do |key|
|
90
|
+
missing_priv = all_priv - found_priv[key].flatten.uniq
|
91
|
+
return_hash[key] = missing_priv unless missing_priv.empty?
|
92
|
+
end
|
93
|
+
return_hash
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
module MysqlVariablesHandling
|
98
|
+
def create_query(option = @option)
|
99
|
+
"SHOW VARIABLES;"
|
100
|
+
end
|
101
|
+
|
102
|
+
def convert_result_to_hash(result)
|
103
|
+
ret = {}
|
104
|
+
result.each do |record|
|
105
|
+
k = record['Variable_name']
|
106
|
+
v = record['Value']
|
107
|
+
ret[k] = v
|
108
|
+
end
|
109
|
+
ret
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
class BinlogParameterChecker < MysqlCompatibilityChecker
|
114
|
+
include MysqlVariablesHandling
|
115
|
+
|
116
|
+
SYS_VAR_TO_CHECK = {
|
117
|
+
# parameter => expected value
|
118
|
+
'binlog_format'=>'ROW',
|
119
|
+
'binlog_checksum'=>'NONE',
|
120
|
+
'log_bin_use_v1_row_events'=>'ON',
|
121
|
+
'log_slave_updates'=>'ON'
|
122
|
+
}
|
123
|
+
|
124
|
+
def check_result(result, option = @option)
|
125
|
+
errors = {}
|
126
|
+
param_hash = convert_result_to_hash(result)
|
127
|
+
|
128
|
+
SYS_VAR_TO_CHECK.each_key do |sys_var|
|
129
|
+
if param_hash.has_key?(sys_var)
|
130
|
+
actual_val = param_hash[sys_var]
|
131
|
+
expected_val = SYS_VAR_TO_CHECK[sys_var]
|
132
|
+
unless actual_val == expected_val
|
133
|
+
errors[sys_var] = actual_val
|
134
|
+
end
|
135
|
+
elsif not %w(binlog_checksum log_bin_use_v1_row_events).include?(sys_var)
|
136
|
+
# Mark variables that do not exist as error
|
137
|
+
errors[sys_var] = false
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
unless errors.empty?
|
142
|
+
error_explanation = ""
|
143
|
+
errors.each_key do |err_key|
|
144
|
+
error_explanation << "\n * #{err_key} is #{errors[err_key]} but should be #{SYS_VAR_TO_CHECK[err_key]}"
|
145
|
+
end
|
146
|
+
raise FlydataCore::MysqlCompatibilityError,
|
147
|
+
"These system variable(s) are not the correct value: #{error_explanation}\n" +
|
148
|
+
" Please change these system variables for FlyData Sync to run correctly"
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
class TableTypeChecker < MysqlCompatibilityChecker
|
154
|
+
SELECT_TABLE_INFO_TMPLT =
|
155
|
+
"SELECT table_name, table_type, engine " +
|
156
|
+
"FROM information_schema.tables " +
|
157
|
+
"WHERE table_schema = '%s' and table_name in (%s)"
|
158
|
+
|
159
|
+
# option[:client] : Mysql2::Client object
|
160
|
+
# option[:tables] : target table list
|
161
|
+
def create_query(option = @option)
|
162
|
+
SELECT_TABLE_INFO_TMPLT % [
|
163
|
+
Mysql2::Client.escape(option[:database]),
|
164
|
+
option[:tables].collect{|t| "'#{Mysql2::Client.escape(t)}'"}.join(", ")
|
165
|
+
]
|
166
|
+
end
|
167
|
+
|
168
|
+
def check_result(result, option = @option)
|
169
|
+
invalid_tables = []
|
170
|
+
result.each do |r|
|
171
|
+
invalid_tables.push(r['table_name']) if r['table_type'] == 'VIEW' || r['engine'] == 'MEMORY'
|
172
|
+
end
|
173
|
+
|
174
|
+
unless invalid_tables.empty?
|
175
|
+
raise FlydataCore::MysqlCompatibilityError,
|
176
|
+
"FlyData does not support VIEW and MEMORY ENGINE table. " +
|
177
|
+
"Remove following tables from data entry: #{invalid_tables.join(", ")}"
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
class NonRdsRetentionChecker < MysqlCompatibilityChecker
|
183
|
+
include MysqlVariablesHandling
|
184
|
+
|
185
|
+
BINLOG_RETENTION_HOURS = 24
|
186
|
+
EXPIRE_LOGS_DAYS_LIMIT = BINLOG_RETENTION_HOURS / 24
|
187
|
+
|
188
|
+
def check_result(result, option = @option)
|
189
|
+
param_hash = convert_result_to_hash(result)
|
190
|
+
|
191
|
+
if param_hash["expire_logs_days"].to_s != '0' &&
|
192
|
+
param_hash["expire_logs_days"].to_i <= EXPIRE_LOGS_DAYS_LIMIT
|
193
|
+
raise FlydataCore::MysqlCompatibilityError,
|
194
|
+
"Binary log retention is too short\n " +
|
195
|
+
" We recommend the system variable '@@expire_logs_days' to be either set to 0 or at least #{EXPIRE_LOGS_DAYS_LIMIT} day(s)"
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
class RdsRetentionChecker < MysqlCompatibilityChecker
|
201
|
+
BINLOG_RETENTION_HOURS = NonRdsRetentionChecker::BINLOG_RETENTION_HOURS
|
202
|
+
|
203
|
+
def create_query(option = @option)
|
204
|
+
"call mysql.rds_show_configuration;"
|
205
|
+
end
|
206
|
+
|
207
|
+
def check_result(result, option = @option)
|
208
|
+
if result.first["name"] == "binlog retention hours"
|
209
|
+
if result.first["value"].nil? ||
|
210
|
+
result.first["value"].to_i <= BINLOG_RETENTION_HOURS
|
211
|
+
raise FlydataCore::MysqlCompatibilityError,
|
212
|
+
"Binary log retention is too short\n" +
|
213
|
+
" We recommend setting RDS binlog retention " +
|
214
|
+
"to be at least #{BINLOG_RETENTION_HOURS} hours. To do this, " +
|
215
|
+
"run this on your RDS MySQL database:\n" +
|
216
|
+
" $> call mysql.rds_set_configuration('binlog retention hours', 94);"
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
@@ -0,0 +1,7 @@
|
|
1
|
+
# QueryJob is a library provding an interfact to the Query Job Processor (also
|
2
|
+
# known as Copy Handler)
|
3
|
+
# SimpleDB related classes and methods defined in flydata and flydata-web will
|
4
|
+
# be eventually consolidated into this library.
|
5
|
+
Dir[File.join(File.dirname(__FILE__), "query_job/**/*.rb")].each do |f|
|
6
|
+
require f
|
7
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'flydata-core/table_def/redshift_table_def'
|
2
|
+
|
3
|
+
module FlydataCore
|
4
|
+
module QueryJob
|
5
|
+
|
6
|
+
class Redshift
|
7
|
+
# returns a list of table names used in the Query Job system.
|
8
|
+
def self.target_table_names(flydata_table_names)
|
9
|
+
flydata_table_names = [ flydata_table_names ] unless flydata_table_names.kind_of?(Array)
|
10
|
+
|
11
|
+
flydata_table_names.collect {|flydata_table_name|
|
12
|
+
redshift_table_name = TableDef::RedshiftTableDef.convert_to_valid_table_name(flydata_table_name)
|
13
|
+
table_names = [ redshift_table_name ]
|
14
|
+
if redshift_table_name != flydata_table_name
|
15
|
+
# for backward compatibility
|
16
|
+
# Old implementation used FlyData table names. To support it, the
|
17
|
+
# method adds the flydata_table_name as well.
|
18
|
+
table_names << flydata_table_name
|
19
|
+
end
|
20
|
+
|
21
|
+
table_names
|
22
|
+
}.flatten
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
data/{spec/flydata/mysql/mysql_util_spec.rb → flydata-core/spec/mysql/command_generator_spec.rb}
RENAMED
@@ -1,10 +1,10 @@
|
|
1
1
|
require 'spec_helper'
|
2
|
-
require 'flydata/mysql/
|
2
|
+
require 'flydata-core/mysql/command_generator'
|
3
3
|
|
4
|
-
module
|
4
|
+
module FlydataCore
|
5
5
|
module Mysql
|
6
6
|
|
7
|
-
describe
|
7
|
+
describe CommandGenerator do
|
8
8
|
let(:default_option) { {
|
9
9
|
command: 'mysqldump',
|
10
10
|
host: 'test-host',
|
@@ -139,6 +139,25 @@ module Flydata
|
|
139
139
|
'mysql -h test-host -P 3306 -utestuser -p"testpassword" --default-character-set=utf8 --protocol=tcp -e "SHOW GRANTS;" testdb'
|
140
140
|
) }
|
141
141
|
end
|
142
|
+
|
143
|
+
describe '.each_mysql_tabledef' do
|
144
|
+
context 'with empty tables' do
|
145
|
+
subject { described_class.each_mysql_tabledef([], nil) }
|
146
|
+
it do
|
147
|
+
expect{subject}.to raise_error(
|
148
|
+
ArgumentError,"tables is nil or empty")
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
context 'when all tables are missing' do
|
153
|
+
before do
|
154
|
+
allow(described_class).to receive(:_each_mysql_tabledef).
|
155
|
+
and_raise(CommandGenerator::TableMissingError.new('table, missing error', 'table1'))
|
156
|
+
end
|
157
|
+
subject { described_class.each_mysql_tabledef(%w(table1), nil) }
|
158
|
+
it { is_expected.to eq(%w(table1)) }
|
159
|
+
end
|
160
|
+
end
|
142
161
|
end
|
143
162
|
end
|
144
163
|
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'flydata-core/query_job/redshift'
|
3
|
+
|
4
|
+
module FlydataCore::QueryJob
|
5
|
+
|
6
|
+
describe Redshift do
|
7
|
+
describe '.target_table_names' do
|
8
|
+
subject { described_class.target_table_names(flydata_table_names) }
|
9
|
+
|
10
|
+
context 'with a single table name' do
|
11
|
+
let(:flydata_table_names) { "my_table" }
|
12
|
+
|
13
|
+
it { is_expected.to eq [ flydata_table_names ] }
|
14
|
+
end
|
15
|
+
|
16
|
+
context 'with multiples table names' do
|
17
|
+
let(:flydata_table_names) { %w(my_table1 my_table2) }
|
18
|
+
|
19
|
+
it { is_expected.to eq flydata_table_names }
|
20
|
+
end
|
21
|
+
context 'with a mixed case table name' do
|
22
|
+
let(:flydata_table_names) { "MyTable" }
|
23
|
+
|
24
|
+
it { is_expected.to eq [ flydata_table_names.downcase, flydata_table_names ] }
|
25
|
+
end
|
26
|
+
context 'with table names including mixed case one' do
|
27
|
+
let(:flydata_table_names) { %w(MyTable my_table2) }
|
28
|
+
|
29
|
+
it { is_expected.to eq %w(mytable MyTable my_table2) }
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|