groupdate 4.3.0 → 5.2.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,51 @@
1
+ module Groupdate
2
+ module Adapters
3
+ class SQLiteAdapter < BaseAdapter
4
+ def group_clause
5
+ raise Groupdate::Error, "Time zones not supported for SQLite" unless @time_zone.utc_offset.zero?
6
+ raise Groupdate::Error, "day_start not supported for SQLite" unless day_start.zero?
7
+
8
+ query =
9
+ if period == :week
10
+ ["strftime('%Y-%m-%d 00:00:00 UTC', #{column}, '-6 days', ?)", "weekday #{(week_start + 1) % 7}"]
11
+ elsif period == :custom
12
+ ["datetime((strftime('%s', #{column}) / ?) * ?, 'unixepoch')", n_seconds, n_seconds]
13
+ else
14
+ format =
15
+ case period
16
+ when :minute_of_hour
17
+ "%M"
18
+ when :hour_of_day
19
+ "%H"
20
+ when :day_of_week
21
+ "%w"
22
+ when :day_of_month
23
+ "%d"
24
+ when :day_of_year
25
+ "%j"
26
+ when :month_of_year
27
+ "%m"
28
+ when :second
29
+ "%Y-%m-%d %H:%M:%S UTC"
30
+ when :minute
31
+ "%Y-%m-%d %H:%M:00 UTC"
32
+ when :hour
33
+ "%Y-%m-%d %H:00:00 UTC"
34
+ when :day
35
+ "%Y-%m-%d 00:00:00 UTC"
36
+ when :month
37
+ "%Y-%m-01 00:00:00 UTC"
38
+ when :quarter
39
+ raise Groupdate::Error, "Quarter not supported for SQLite"
40
+ else # year
41
+ "%Y-01-01 00:00:00 UTC"
42
+ end
43
+
44
+ ["strftime(?, #{column})", format]
45
+ end
46
+
47
+ @relation.send(:sanitize_sql_array, query)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -2,11 +2,10 @@ module Enumerable
2
2
  Groupdate::PERIODS.each do |period|
3
3
  define_method :"group_by_#{period}" do |*args, **options, &block|
4
4
  if block
5
- # TODO throw error in Groupdate 5
6
- warn "[groupdate] positional arguments are deprecated" if args.any?
7
- Groupdate::Magic::Enumerable.group_by(self, period, (args[0] || {}).merge(options), &block)
5
+ raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0)" if args.any?
6
+ Groupdate::Magic::Enumerable.group_by(self, period, options, &block)
8
7
  elsif respond_to?(:scoping)
9
- scoping { @klass.send(:"group_by_#{period}", *args, **options, &block) }
8
+ scoping { @klass.group_by_period(period, *args, **options, &block) }
10
9
  else
11
10
  raise ArgumentError, "no block given"
12
11
  end
@@ -15,18 +14,12 @@ module Enumerable
15
14
 
16
15
  def group_by_period(period, *args, **options, &block)
17
16
  if block || !respond_to?(:scoping)
18
- # TODO throw error in Groupdate 5
19
- warn "[groupdate] positional arguments are deprecated" if args.any?
20
- options = (args[0] || {}).merge(options)
21
- # to_sym is unsafe on user input, so convert to strings
22
- permitted_periods = ((options.delete(:permit) || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
23
- if permitted_periods.include?(period.to_s)
24
- send("group_by_#{period}", **options, &block)
25
- else
26
- raise ArgumentError, "Unpermitted period"
27
- end
17
+ raise ArgumentError, "wrong number of arguments (given #{args.size + 1}, expected 1)" if args.any?
18
+
19
+ Groupdate::Magic.validate_period(period, options.delete(:permit))
20
+ send("group_by_#{period}", **options, &block)
28
21
  else
29
- scoping { @klass.send(:group_by_period, period, *args, **options, &block) }
22
+ scoping { @klass.group_by_period(period, *args, **options, &block) }
30
23
  end
31
24
  end
32
25
  end
@@ -2,18 +2,52 @@ require "i18n"
2
2
 
3
3
  module Groupdate
4
4
  class Magic
5
- attr_accessor :period, :options, :group_index
5
+ DAYS = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
6
+
7
+ attr_accessor :period, :options, :group_index, :n_seconds
6
8
 
7
9
  def initialize(period:, **options)
8
10
  @period = period
9
11
  @options = options
10
12
 
11
- unknown_keywords = options.keys - [:day_start, :time_zone, :dates, :series, :week_start, :format, :locale, :range, :reverse]
13
+ validate_keywords
14
+ validate_arguments
15
+
16
+ if options[:n]
17
+ raise ArgumentError, "n must be a positive integer" if !options[:n].is_a?(Integer) || options[:n] < 1
18
+ @period = :custom
19
+ @n_seconds = options[:n].to_i
20
+ @n_seconds *= 60 if period == :minute
21
+ end
22
+ end
23
+
24
+ def validate_keywords
25
+ known_keywords = [:time_zone, :dates, :series, :format, :locale, :range, :reverse]
26
+
27
+ if %i[week day_of_week].include?(period)
28
+ known_keywords << :week_start
29
+ end
30
+
31
+ if %i[day week month quarter year day_of_week hour_of_day day_of_month day_of_year month_of_year].include?(period)
32
+ known_keywords << :day_start
33
+ else
34
+ # prevent Groupdate.day_start from applying
35
+ @day_start = 0
36
+ end
37
+
38
+ if %i[second minute].include?(period)
39
+ known_keywords << :n
40
+ end
41
+
42
+ unknown_keywords = options.keys - known_keywords
12
43
  raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
44
+ end
13
45
 
14
- raise Groupdate::Error, "Unrecognized time zone" unless time_zone
15
- raise Groupdate::Error, "Unrecognized :week_start option" if period == :week && !week_start
16
- raise Groupdate::Error, "Cannot use endless range for :range option" if options[:range].is_a?(Range) && !options[:range].end
46
+ def validate_arguments
47
+ # TODO better messages
48
+ raise ArgumentError, "Unrecognized time zone" unless time_zone
49
+ raise ArgumentError, "Unrecognized :week_start option" unless week_start
50
+ raise ArgumentError, ":day_start must be between 0 and 24" if (day_start / 3600) < 0 || (day_start / 3600) >= 24
17
51
  end
18
52
 
19
53
  def time_zone
@@ -25,21 +59,38 @@ module Groupdate
25
59
  end
26
60
 
27
61
  def week_start
28
- @week_start ||= [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index((options[:week_start] || options[:start] || Groupdate.week_start).to_sym)
62
+ @week_start ||= begin
63
+ v = (options[:week_start] || Groupdate.week_start).to_sym
64
+ DAYS.index(v) || [:mon, :tue, :wed, :thu, :fri, :sat, :sun].index(v)
65
+ end
29
66
  end
30
67
 
31
68
  def day_start
32
69
  @day_start ||= ((options[:day_start] || Groupdate.day_start).to_f * 3600).round
33
70
  end
34
71
 
72
+ def range
73
+ @range ||= begin
74
+ time_range = options[:range]
75
+
76
+ if time_range.is_a?(Range) && time_range.begin.nil? && time_range.end.nil?
77
+ nil
78
+ else
79
+ time_range
80
+ end
81
+ end
82
+ end
83
+
35
84
  def series_builder
36
85
  @series_builder ||=
37
86
  SeriesBuilder.new(
38
87
  **options,
39
88
  period: period,
40
89
  time_zone: time_zone,
90
+ range: range,
41
91
  day_start: day_start,
42
- week_start: week_start
92
+ week_start: week_start,
93
+ n_seconds: n_seconds
43
94
  )
44
95
  end
45
96
 
@@ -47,6 +98,11 @@ module Groupdate
47
98
  series_builder.time_range
48
99
  end
49
100
 
101
+ def self.validate_period(period, permit)
102
+ permitted_periods = ((permit || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
103
+ raise ArgumentError, "Unpermitted period" unless permitted_periods.include?(period.to_s)
104
+ end
105
+
50
106
  class Enumerable < Magic
51
107
  def group_by(enum, &_block)
52
108
  group = enum.group_by do |v|
@@ -85,10 +141,10 @@ module Groupdate
85
141
  def cast_method
86
142
  @cast_method ||= begin
87
143
  case period
144
+ when :minute_of_hour, :hour_of_day, :day_of_month, :day_of_year, :month_of_year
145
+ lambda { |k| k.to_i }
88
146
  when :day_of_week
89
147
  lambda { |k| (k.to_i - 1 - week_start) % 7 }
90
- when :hour_of_day, :day_of_month, :day_of_year, :month_of_year, :minute_of_hour
91
- lambda { |k| k.to_i }
92
148
  else
93
149
  utc = ActiveSupport::TimeZone["UTC"]
94
150
  lambda { |k| (k.is_a?(String) || !k.respond_to?(:to_time) ? utc.parse(k.to_s) : k.to_time).in_time_zone(time_zone) }
@@ -133,16 +189,21 @@ module Groupdate
133
189
  def self.generate_relation(relation, field:, **options)
134
190
  magic = Groupdate::Magic::Relation.new(**options)
135
191
 
192
+ adapter_name = relation.connection.adapter_name
193
+ adapter = Groupdate.adapters[adapter_name]
194
+ raise Groupdate::Error, "Connection adapter not supported: #{adapter_name}" unless adapter
195
+
136
196
  # generate ActiveRecord relation
137
197
  relation =
138
- RelationBuilder.new(
198
+ adapter.new(
139
199
  relation,
140
200
  column: field,
141
201
  period: magic.period,
142
202
  time_zone: magic.time_zone,
143
203
  time_range: magic.time_range,
144
204
  week_start: magic.week_start,
145
- day_start: magic.day_start
205
+ day_start: magic.day_start,
206
+ n_seconds: magic.n_seconds
146
207
  ).generate
147
208
 
148
209
  # add Groupdate info
@@ -1,27 +1,18 @@
1
1
  module Groupdate
2
2
  module QueryMethods
3
3
  Groupdate::PERIODS.each do |period|
4
- define_method :"group_by_#{period}" do |field, time_zone = nil, range = nil, **options|
5
- warn "[groupdate] positional arguments for time zone and range are deprecated" if time_zone || range
6
-
4
+ define_method :"group_by_#{period}" do |field, **options|
7
5
  Groupdate::Magic::Relation.generate_relation(self,
8
6
  period: period,
9
7
  field: field,
10
- time_zone: time_zone,
11
- range: range,
12
8
  **options
13
9
  )
14
10
  end
15
11
  end
16
12
 
17
13
  def group_by_period(period, field, permit: nil, **options)
18
- # to_sym is unsafe on user input, so convert to strings
19
- permitted_periods = ((permit || Groupdate::PERIODS).map(&:to_sym) & Groupdate::PERIODS).map(&:to_s)
20
- if permitted_periods.include?(period.to_s)
21
- send("group_by_#{period}", field, **options)
22
- else
23
- raise ArgumentError, "Unpermitted period"
24
- end
14
+ Groupdate::Magic.validate_period(period, permit)
15
+ send("group_by_#{period}", field, **options)
25
16
  end
26
17
  end
27
18
  end
@@ -1,29 +1,64 @@
1
1
  module Groupdate
2
2
  class SeriesBuilder
3
- attr_reader :period, :time_zone, :day_start, :week_start, :options
3
+ attr_reader :period, :time_zone, :day_start, :week_start, :n_seconds, :options
4
4
 
5
5
  CHECK_PERIODS = [:day, :week, :month, :quarter, :year]
6
6
 
7
- def initialize(period:, time_zone:, day_start:, week_start:, **options)
7
+ def initialize(period:, time_zone:, day_start:, week_start:, n_seconds:, **options)
8
8
  @period = period
9
9
  @time_zone = time_zone
10
10
  @week_start = week_start
11
11
  @day_start = day_start
12
+ @n_seconds = n_seconds
12
13
  @options = options
13
- @round_time = {}
14
+ @week_start_key = Groupdate::Magic::DAYS[@week_start] if @week_start
14
15
  end
15
16
 
16
17
  def generate(data, default_value:, series_default: true, multiple_groups: false, group_index: nil)
17
18
  series = generate_series(data, multiple_groups, group_index)
18
19
  series = handle_multiple(data, series, multiple_groups, group_index)
19
20
 
21
+ verified_data = {}
22
+ series.each do |k|
23
+ verified_data[k] = data.delete(k)
24
+ end
25
+
26
+ # this is a fun one
27
+ # PostgreSQL and Ruby both return the 2nd hour when converting/parsing a backward DST change
28
+ # Other databases and Active Support return the 1st hour (as expected)
29
+ # Active Support good: ActiveSupport::TimeZone["America/Los_Angeles"].parse("2013-11-03 01:00:00")
30
+ # MySQL good: SELECT CONVERT_TZ('2013-11-03 01:00:00', 'America/Los_Angeles', 'Etc/UTC');
31
+ # Ruby not good: Time.parse("2013-11-03 01:00:00")
32
+ # PostgreSQL not good: SELECT '2013-11-03 01:00:00'::timestamp AT TIME ZONE 'America/Los_Angeles';
33
+ # we need to account for this here
34
+ if series_default && CHECK_PERIODS.include?(period)
35
+ data.each do |k, v|
36
+ key = multiple_groups ? k[group_index] : k
37
+ # TODO only do this for PostgreSQL
38
+ # this may mask some inconsistent time zone errors
39
+ # but not sure there's a better approach
40
+ if key.hour == (key - 1.hour).hour && series.include?(key - 1.hour)
41
+ key -= 1.hour
42
+ if multiple_groups
43
+ k[group_index] = key
44
+ else
45
+ k = key
46
+ end
47
+ verified_data[k] = v
48
+ elsif key != round_time(key)
49
+ # only need to show what database returned since it will cast in Ruby time zone
50
+ raise Groupdate::Error, "Database and Ruby have inconsistent time zone info. Database returned #{key}"
51
+ end
52
+ end
53
+ end
54
+
20
55
  unless entire_series?(series_default)
21
- series = series.select { |k| data[k] }
56
+ series = series.select { |k| verified_data[k] }
22
57
  end
23
58
 
24
59
  value = 0
25
60
  result = Hash[series.map do |k|
26
- value = data.delete(k) || (@options[:carry_forward] && value) || default_value
61
+ value = verified_data[k] || (@options[:carry_forward] && value) || default_value
27
62
  key =
28
63
  if multiple_groups
29
64
  k[0...group_index] + [key_format.call(k[group_index])] + k[(group_index + 1)..-1]
@@ -34,20 +69,21 @@ module Groupdate
34
69
  [key, value]
35
70
  end]
36
71
 
37
- # only check for database
38
- # only checks remaining keys to avoid expensive calls to round_time
39
- if series_default && CHECK_PERIODS.include?(period)
40
- check_consistent_time_zone_info(data, multiple_groups, group_index)
41
- end
42
-
43
72
  result
44
73
  end
45
74
 
46
75
  def round_time(time)
76
+ if period == :custom
77
+ return time_zone.at((time.to_time.to_i / n_seconds) * n_seconds)
78
+ end
79
+
47
80
  time = time.to_time.in_time_zone(time_zone)
48
81
 
49
- # only if day_start != 0 for performance
50
- time -= day_start.seconds if day_start != 0
82
+ if day_start != 0
83
+ # apply day_start to a time object that's not affected by DST
84
+ time = change_zone.call(time, utc)
85
+ time -= day_start.seconds
86
+ end
51
87
 
52
88
  time =
53
89
  case period
@@ -60,9 +96,7 @@ module Groupdate
60
96
  when :day
61
97
  time.beginning_of_day
62
98
  when :week
63
- # same logic as MySQL group
64
- weekday = (time.wday - 1) % 7
65
- (time - ((7 - week_start + weekday) % 7).days).midnight
99
+ time.beginning_of_week(@week_start_key)
66
100
  when :month
67
101
  time.beginning_of_month
68
102
  when :quarter
@@ -74,7 +108,7 @@ module Groupdate
74
108
  when :minute_of_hour
75
109
  time.min
76
110
  when :day_of_week
77
- (time.wday - 1 - week_start) % 7
111
+ time.days_to_week_start(@week_start_key)
78
112
  when :day_of_month
79
113
  time.day
80
114
  when :month_of_year
@@ -85,24 +119,62 @@ module Groupdate
85
119
  raise Groupdate::Error, "Invalid period"
86
120
  end
87
121
 
88
- # only if day_start != 0 for performance
89
- time += day_start.seconds if day_start != 0 && time.is_a?(Time)
122
+ if day_start != 0 && time.is_a?(Time)
123
+ time += day_start.seconds
124
+ time = change_zone.call(time, time_zone)
125
+ end
90
126
 
91
127
  time
92
128
  end
93
129
 
130
+ def change_zone
131
+ @change_zone ||= begin
132
+ if ActiveSupport::VERSION::STRING >= "5.2"
133
+ ->(time, zone) { time.change(zone: zone) }
134
+ else
135
+ # TODO make more efficient
136
+ ->(time, zone) { zone.parse(time.strftime("%Y-%m-%d %H:%M:%S")) }
137
+ end
138
+ end
139
+ end
140
+
94
141
  def time_range
95
142
  @time_range ||= begin
96
143
  time_range = options[:range]
97
- if time_range.is_a?(Range) && time_range.first.is_a?(Date)
98
- # convert range of dates to range of times
99
- # use parsing instead of in_time_zone due to Rails < 4
100
- last = time_zone.parse(time_range.last.to_s)
101
- last += 1.day unless time_range.exclude_end?
102
- time_range = Range.new(time_zone.parse(time_range.first.to_s), last, true)
144
+
145
+ if time_range.is_a?(Range)
146
+ # check types
147
+ [time_range.begin, time_range.end].each do |v|
148
+ case v
149
+ when nil, Date, Time
150
+ # good
151
+ when String
152
+ # TODO raise error in Groupdate 6
153
+ warn "[groupdate] Range bounds should be Date or Time, not #{v.class.name}. This will raise an error in Groupdate 6"
154
+ break
155
+ else
156
+ raise ArgumentError, "Range bounds should be Date or Time, not #{v.class.name}"
157
+ end
158
+ end
159
+
160
+ start = time_range.begin
161
+ start = start.in_time_zone(time_zone) if start
162
+
163
+ exclude_end = time_range.exclude_end?
164
+
165
+ finish = time_range.end
166
+ finish = finish.in_time_zone(time_zone) if finish
167
+ if time_range.end.is_a?(Date) && !exclude_end
168
+ finish += 1.day
169
+ exclude_end = true
170
+ end
171
+
172
+ time_range = Range.new(start, finish, exclude_end)
103
173
  elsif !time_range && options[:last]
104
174
  if period == :quarter
105
175
  step = 3.months
176
+ elsif period == :custom
177
+ step = n_seconds
106
178
  elsif 1.respond_to?(period)
107
179
  step = 1.send(period)
108
180
  else
@@ -119,7 +191,8 @@ module Groupdate
119
191
  if options[:current] == false
120
192
  round_time(start_at - step)...round_time(now)
121
193
  else
122
- round_time(start_at)..now
194
+ # extend to end of current period
195
+ round_time(start_at)...(round_time(now) + step)
123
196
  end
124
197
  end
125
198
  end
@@ -150,7 +223,7 @@ module Groupdate
150
223
  else
151
224
  time_range = self.time_range
152
225
  time_range =
153
- if time_range.is_a?(Range)
226
+ if time_range.is_a?(Range) && time_range.begin && time_range.end
154
227
  time_range
155
228
  else
156
229
  # use first and last values
@@ -161,11 +234,23 @@ module Groupdate
161
234
  data.keys.sort
162
235
  end
163
236
 
164
- tr = sorted_keys.first..sorted_keys.last
165
- if options[:current] == false && sorted_keys.any? && round_time(now) >= tr.last
166
- tr = tr.first...round_time(now)
237
+ if time_range.is_a?(Range)
238
+ if sorted_keys.any?
239
+ if time_range.begin
240
+ time_range.begin..sorted_keys.last
241
+ else
242
+ Range.new(sorted_keys.first, time_range.end, time_range.exclude_end?)
243
+ end
244
+ else
245
+ nil..nil
246
+ end
247
+ else
248
+ tr = sorted_keys.first..sorted_keys.last
249
+ if options[:current] == false && sorted_keys.any? && round_time(now) >= tr.last
250
+ tr = tr.first...round_time(now)
251
+ end
252
+ tr
167
253
  end
168
- tr
169
254
  end
170
255
 
171
256
  if time_range.begin
@@ -173,14 +258,17 @@ module Groupdate
173
258
 
174
259
  if period == :quarter
175
260
  step = 3.months
261
+ elsif period == :custom
262
+ step = n_seconds
176
263
  else
177
264
  step = 1.send(period)
178
265
  end
179
266
 
180
267
  last_step = series.last
268
+ day_start_hour = day_start / 3600
181
269
  loop do
182
270
  next_step = last_step + step
183
- next_step = round_time(next_step) if next_step.hour != day_start # add condition to speed up
271
+ next_step = round_time(next_step) if next_step.hour != day_start_hour # add condition to speed up
184
272
  break unless time_range.cover?(next_step)
185
273
 
186
274
  if next_step == last_step
@@ -199,34 +287,36 @@ module Groupdate
199
287
  end
200
288
 
201
289
  def key_format
202
- locale = options[:locale] || I18n.locale
203
- use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
290
+ @key_format ||= begin
291
+ locale = options[:locale] || I18n.locale
292
+ use_dates = options.key?(:dates) ? options[:dates] : Groupdate.dates
204
293
 
205
- if options[:format]
206
- if options[:format].respond_to?(:call)
207
- options[:format]
208
- else
209
- sunday = time_zone.parse("2014-03-02 00:00:00")
210
- lambda do |key|
211
- case period
212
- when :hour_of_day
213
- key = sunday + key.hours + day_start.seconds
214
- when :minute_of_hour
215
- key = sunday + key.minutes + day_start.seconds
216
- when :day_of_week
217
- key = sunday + key.days + (week_start + 1).days
218
- when :day_of_month
219
- key = Date.new(2014, 1, key).to_time
220
- when :month_of_year
221
- key = Date.new(2014, key, 1).to_time
294
+ if options[:format]
295
+ if options[:format].respond_to?(:call)
296
+ options[:format]
297
+ else
298
+ sunday = time_zone.parse("2014-03-02 00:00:00")
299
+ lambda do |key|
300
+ case period
301
+ when :hour_of_day
302
+ key = sunday + key.hours + day_start.seconds
303
+ when :minute_of_hour
304
+ key = sunday + key.minutes + day_start.seconds
305
+ when :day_of_week
306
+ key = sunday + key.days + (week_start + 1).days
307
+ when :day_of_month
308
+ key = Date.new(2014, 1, key).to_time
309
+ when :month_of_year
310
+ key = Date.new(2014, key, 1).to_time
311
+ end
312
+ I18n.localize(key, format: options[:format], locale: locale)
222
313
  end
223
- I18n.localize(key, format: options[:format], locale: locale)
224
314
  end
315
+ elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
316
+ lambda { |k| k.to_date }
317
+ else
318
+ lambda { |k| k }
225
319
  end
226
- elsif [:day, :week, :month, :quarter, :year].include?(period) && use_dates
227
- lambda { |k| k.to_date }
228
- else
229
- lambda { |k| k }
230
320
  end
231
321
  end
232
322
 
@@ -246,23 +336,12 @@ module Groupdate
246
336
  end
247
337
  end
248
338
 
249
- def check_consistent_time_zone_info(data, multiple_groups, group_index)
250
- keys = data.keys
251
- if multiple_groups
252
- keys.map! { |k| k[group_index] }
253
- keys.uniq!
254
- end
255
-
256
- keys.each do |key|
257
- if key != round_time(key)
258
- # only need to show what database returned since it will cast in Ruby time zone
259
- raise Groupdate::Error, "Database and Ruby have inconsistent time zone info. Database returned #{key}"
260
- end
261
- end
262
- end
263
-
264
339
  def entire_series?(series_default)
265
340
  options.key?(:series) ? options[:series] : series_default
266
341
  end
342
+
343
+ def utc
344
+ @utc ||= ActiveSupport::TimeZone["Etc/UTC"]
345
+ end
267
346
  end
268
347
  end