odba 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,628 @@
1
+ #!/usr/bin/env ruby
2
+ #-- Storage -- odba -- 29.04.2004 -- hwyss@ywesee.com rwaltert@ywesee.com mwalder@ywesee.com
3
+
4
+ require 'thread'
5
+ require 'singleton'
6
+ require 'dbi'
7
+
8
+ module ODBA
9
+ class Storage # :nodoc: all
10
+ include Singleton
11
+ attr_writer :dbi
12
+ BULK_FETCH_STEP = 2500
13
+ TABLES = [
14
+ # in table 'object', the isolated dumps of all objects are stored
15
+ ['object', <<-'SQL'],
16
+ CREATE TABLE object (
17
+ odba_id INTEGER NOT NULL, content TEXT,
18
+ name TEXT, prefetchable BOOLEAN, extent TEXT,
19
+ PRIMARY KEY(odba_id), UNIQUE(name)
20
+ );
21
+ SQL
22
+ ['prefetchable_index', <<-SQL],
23
+ CREATE INDEX prefetchable_index ON object(prefetchable);
24
+ SQL
25
+ ['extent_index', <<-SQL],
26
+ CREATE INDEX extent_index ON object(extent);
27
+ SQL
28
+ # helper table 'object_connection'
29
+ ['object_connection', <<-'SQL'],
30
+ CREATE TABLE object_connection (
31
+ origin_id integer, target_id integer,
32
+ PRIMARY KEY(origin_id, target_id)
33
+ );
34
+ SQL
35
+ ['target_id_index', <<-SQL],
36
+ CREATE INDEX target_id_index ON object_connection(target_id);
37
+ SQL
38
+ # helper table 'collection'
39
+ ['collection', <<-'SQL'],
40
+ CREATE TABLE collection (
41
+ odba_id integer NOT NULL, key text, value text,
42
+ PRIMARY KEY(odba_id, key)
43
+ );
44
+ SQL
45
+ ]
46
+ def initialize
47
+ @id_mutex = Mutex.new
48
+ end
49
+ def bulk_restore(bulk_fetch_ids)
50
+ if(bulk_fetch_ids.empty?)
51
+ []
52
+ else
53
+ bulk_fetch_ids = bulk_fetch_ids.uniq
54
+ rows = []
55
+ while(!(ids = bulk_fetch_ids.slice!(0, BULK_FETCH_STEP)).empty?)
56
+ sql = <<-SQL
57
+ SELECT odba_id, content FROM object
58
+ WHERE odba_id IN (#{ids.join(',')})
59
+ SQL
60
+ rows.concat(self.dbi.select_all(sql))
61
+ end
62
+ rows
63
+ end
64
+ end
65
+ def collection_fetch(odba_id, key_dump)
66
+ sql = <<-SQL
67
+ SELECT value FROM collection
68
+ WHERE odba_id = ? AND key = ?
69
+ SQL
70
+ row = self.dbi.select_one(sql, odba_id, key_dump)
71
+ row.first unless row.nil?
72
+ end
73
+ def collection_remove(odba_id, key_dump)
74
+ self.dbi.do <<-SQL, odba_id, key_dump
75
+ DELETE FROM collection
76
+ WHERE odba_id = ? AND key = ?
77
+ SQL
78
+ end
79
+ def collection_store(odba_id, key_dump, value_dump)
80
+ self.dbi.do <<-SQL, odba_id, key_dump, value_dump
81
+ INSERT INTO collection (odba_id, key, value)
82
+ VALUES (?, ?, ?)
83
+ SQL
84
+ end
85
+ def condition_index_delete(index_name, origin_id,
86
+ search_terms, target_id=nil)
87
+ values = []
88
+ sql = "DELETE FROM #{index_name}"
89
+ if(origin_id)
90
+ sql << " WHERE origin_id = ?"
91
+ else
92
+ sql << " WHERE origin_id IS ?"
93
+ end
94
+ search_terms.each { |key, value|
95
+ sql << " AND %s = ?" % key
96
+ values << value
97
+ }
98
+ if(target_id)
99
+ sql << " AND target_id = ?"
100
+ values << target_id
101
+ end
102
+ self.dbi.do sql, origin_id, *values
103
+ end
104
+ def condition_index_ids(index_name, id, id_name)
105
+ sql = <<-SQL
106
+ SELECT DISTINCT *
107
+ FROM #{index_name}
108
+ WHERE #{id_name}=?
109
+ SQL
110
+ self.dbi.select_all(sql, id)
111
+ end
112
+ def create_dictionary_map(language)
113
+ %w{lhword lpart_hword lword}.each { |token|
114
+ self.dbi.do <<-SQL
115
+ INSERT INTO pg_ts_cfgmap (ts_name, tok_alias, dict_name)
116
+ VALUES ('default_#{language}', '#{token}',
117
+ '{#{language}_ispell,#{language}_stem}')
118
+ SQL
119
+ }
120
+ [ 'url', 'host', 'sfloat', 'uri', 'int', 'float', 'email',
121
+ 'word', 'hword', 'nlword', 'nlpart_hword', 'part_hword',
122
+ 'nlhword', 'file', 'uint', 'version'
123
+ ].each { |token|
124
+ self.dbi.do <<-SQL
125
+ INSERT INTO pg_ts_cfgmap (ts_name, tok_alias, dict_name)
126
+ VALUES ('default_#{language}', '#{token}', '{simple}')
127
+ SQL
128
+ }
129
+ end
130
+ def create_condition_index(table_name, definition)
131
+ self.dbi.do <<-SQL
132
+ CREATE TABLE #{table_name} (
133
+ origin_id INTEGER,
134
+ #{definition.collect { |*pair| pair.join(' ') }.join(",\n ") },
135
+ target_id INTEGER
136
+ );
137
+ SQL
138
+ #index origin_id
139
+ self.dbi.do <<-SQL
140
+ CREATE INDEX origin_id_#{table_name} ON #{table_name}(origin_id);
141
+ SQL
142
+ #index search_term
143
+ definition.each { |name, datatype|
144
+ self.dbi.do <<-SQL
145
+ CREATE INDEX #{name}_#{table_name} ON #{table_name}(#{name});
146
+ SQL
147
+ }
148
+ #index target_id
149
+ self.dbi.do <<-SQL
150
+ CREATE INDEX target_id_#{table_name} ON #{table_name}(target_id);
151
+ SQL
152
+ end
153
+ def create_fulltext_index(table_name)
154
+ self.dbi.do <<-SQL
155
+ CREATE TABLE #{table_name} (
156
+ origin_id INTEGER,
157
+ search_term tsvector,
158
+ target_id INTEGER
159
+ );
160
+ SQL
161
+ #index origin_id
162
+ self.dbi.do <<-SQL
163
+ CREATE INDEX origin_id_#{table_name} ON #{table_name}(origin_id);
164
+ SQL
165
+ #index search_term
166
+ self.dbi.do <<-SQL
167
+ CREATE INDEX search_term_#{table_name}
168
+ ON #{table_name} USING gist(search_term);
169
+ SQL
170
+ #index target_id
171
+ self.dbi.do <<-SQL
172
+ CREATE INDEX target_id_#{table_name} ON #{table_name}(target_id);
173
+ SQL
174
+ end
175
+ def create_index(table_name)
176
+ self.dbi.do <<-SQL
177
+ CREATE TABLE #{table_name} (
178
+ origin_id INTEGER,
179
+ search_term TEXT,
180
+ target_id INTEGER
181
+ );
182
+ SQL
183
+ #index origin_id
184
+ self.dbi.do <<-SQL
185
+ CREATE INDEX origin_id_#{table_name}
186
+ ON #{table_name}(origin_id)
187
+ SQL
188
+ #index search_term
189
+ self.dbi.do <<-SQL
190
+ CREATE INDEX search_term_#{table_name}
191
+ ON #{table_name}(search_term)
192
+ SQL
193
+ #index target_id
194
+ self.dbi.do <<-SQL
195
+ CREATE INDEX target_id_#{table_name}
196
+ ON #{table_name}(target_id)
197
+ SQL
198
+ end
199
+ def dbi
200
+ Thread.current[:txn] || @dbi
201
+ end
202
+ def drop_index(index_name)
203
+ self.dbi.do "DROP TABLE #{index_name}"
204
+ end
205
+ def delete_index_element(index_name, odba_id, id_name)
206
+ self.dbi.do <<-SQL, odba_id
207
+ DELETE FROM #{index_name} WHERE #{id_name} = ?
208
+ SQL
209
+ end
210
+ def delete_persistable(odba_id)
211
+ # delete origin from connections
212
+ self.dbi.do <<-SQL, odba_id
213
+ DELETE FROM object_connection WHERE origin_id = ?
214
+ SQL
215
+ # delete target from connections
216
+ self.dbi.do <<-SQL, odba_id
217
+ DELETE FROM object_connection WHERE target_id = ?
218
+ SQL
219
+ # delete from collections
220
+ self.dbi.do <<-SQL, odba_id
221
+ DELETE FROM collection WHERE odba_id = ?
222
+ SQL
223
+ # delete from objects
224
+ self.dbi.do <<-SQL, odba_id
225
+ DELETE FROM object WHERE odba_id = ?
226
+ SQL
227
+ end
228
+ def ensure_object_connections(origin_id, target_ids)
229
+ sql = <<-SQL
230
+ SELECT target_id FROM object_connection
231
+ WHERE origin_id = ?
232
+ SQL
233
+ target_ids.uniq!
234
+ update_ids = target_ids
235
+ old_ids = []
236
+ ## use self.dbi instead of @dbi to get information about
237
+ ## object_connections previously stored within this transaction
238
+ if(rows = self.dbi.select_all(sql, origin_id))
239
+ old_ids = rows.collect { |row| row[0] }
240
+ old_ids.uniq!
241
+ delete_ids = old_ids - target_ids
242
+ update_ids = target_ids - old_ids
243
+ unless(delete_ids.empty?)
244
+ while(!(ids = delete_ids.slice!(0, BULK_FETCH_STEP)).empty?)
245
+ self.dbi.do <<-SQL, origin_id
246
+ DELETE FROM object_connection
247
+ WHERE origin_id = ? AND target_id IN (#{ids.join(',')})
248
+ SQL
249
+ end
250
+ end
251
+ end
252
+ sth = self.dbi.prepare <<-SQL
253
+ INSERT INTO object_connection (origin_id, target_id)
254
+ VALUES (?, ?)
255
+ SQL
256
+ update_ids.each { |id|
257
+ sth.execute(origin_id, id)
258
+ }
259
+ sth.finish
260
+ end
261
+ def ensure_target_id_index(table_name)
262
+ #index target_id
263
+ self.dbi.do <<-SQL
264
+ CREATE INDEX target_id_#{table_name}
265
+ ON #{table_name}(target_id)
266
+ SQL
267
+ rescue
268
+ end
269
+ def extent_count(klass)
270
+ self.dbi.select_one(<<-EOQ, klass.to_s).first
271
+ SELECT COUNT(odba_id) FROM object WHERE extent = ?
272
+ EOQ
273
+ end
274
+ def extent_ids(klass)
275
+ self.dbi.select_all(<<-EOQ, klass.to_s).flatten
276
+ SELECT odba_id FROM object WHERE extent = ?
277
+ EOQ
278
+ end
279
+ def fulltext_index_delete(index_name, id, id_name)
280
+ self.dbi.do <<-SQL, id
281
+ DELETE FROM #{index_name}
282
+ WHERE #{id_name} = ?
283
+ SQL
284
+ end
285
+ def fulltext_index_target_ids(index_name, origin_id)
286
+ sql = <<-SQL
287
+ SELECT DISTINCT target_id
288
+ FROM #{index_name}
289
+ WHERE origin_id=?
290
+ SQL
291
+ self.dbi.select_all(sql, origin_id)
292
+ end
293
+ def generate_dictionary(language, locale, dict_dir)
294
+ # setup configuration
295
+ self.dbi.do <<-SQL
296
+ INSERT INTO pg_ts_cfg (ts_name, prs_name, locale)
297
+ VALUES ('default_#{language}', 'default', '#{locale}');
298
+ SQL
299
+ # insert path to dictionary
300
+ sql = <<-SQL
301
+ INSERT INTO pg_ts_dict (
302
+ SELECT '#{language}_ispell', dict_init, ?, dict_lexize
303
+ FROM pg_ts_dict
304
+ WHERE dict_name = 'ispell_template'
305
+ );
306
+ SQL
307
+ prepath = File.expand_path("fulltext", dict_dir)
308
+ path = %w{Aff Dict Stop}.collect { |type|
309
+ sprintf('%sFile="%s.%s"', type, prepath, type.downcase)
310
+ }.join(',')
311
+ sth.do sql, path
312
+ create_dictionary_map(language)
313
+ self.dbi.do <<-SQL
314
+ INSERT INTO pg_ts_dict (
315
+ dict_name, dict_init, dict_lexize
316
+ )
317
+ VALUES (
318
+ '#{language}_stem', 'dinit_#{language}(internal)',
319
+ 'snb_lexize(internal, internal, int4)'
320
+ );
321
+ SQL
322
+ end
323
+ def index_delete_origin(index_name, odba_id, term)
324
+ self.dbi.do <<-SQL, odba_id, term
325
+ DELETE FROM #{index_name}
326
+ WHERE origin_id = ?
327
+ AND search_term = ?
328
+ SQL
329
+ end
330
+ def index_delete_target(index_name, origin_id, search_term, target_id)
331
+ self.dbi.do <<-SQL, origin_id, search_term, target_id
332
+ DELETE FROM #{index_name}
333
+ WHERE origin_id = ?
334
+ AND search_term = ?
335
+ AND target_id = ?
336
+ SQL
337
+ end
338
+ def index_fetch_keys(index_name, length=nil)
339
+ expr = if(length)
340
+ "substr(search_term, 1, #{length})"
341
+ else
342
+ "search_term"
343
+ end
344
+ sql = <<-SQL
345
+ SELECT DISTINCT #{expr} AS key
346
+ FROM #{index_name}
347
+ ORDER BY key
348
+ SQL
349
+ self.dbi.select_all(sql).flatten
350
+ end
351
+ def index_matches(index_name, substring, limit=nil, offset=0)
352
+ sql = <<-SQL
353
+ SELECT DISTINCT search_term AS key
354
+ FROM #{index_name}
355
+ WHERE search_term LIKE ?
356
+ ORDER BY key
357
+ SQL
358
+ if limit
359
+ sql << "LIMIT #{limit}\n"
360
+ end
361
+ if offset > 0
362
+ sql << "OFFSET #{offset}\n"
363
+ end
364
+ self.dbi.select_all(sql, substring + '%').flatten
365
+ end
366
+ def index_origin_ids(index_name, target_id)
367
+ sql = <<-SQL
368
+ SELECT DISTINCT origin_id, search_term
369
+ FROM #{index_name}
370
+ WHERE target_id=?
371
+ SQL
372
+ self.dbi.select_all(sql, target_id)
373
+ end
374
+ def index_target_ids(index_name, origin_id)
375
+ sql = <<-SQL
376
+ SELECT DISTINCT target_id, search_term
377
+ FROM #{index_name}
378
+ WHERE origin_id=?
379
+ SQL
380
+ self.dbi.select_all(sql, origin_id)
381
+ end
382
+ def max_id
383
+ @id_mutex.synchronize do
384
+ ensure_next_id_set
385
+ @next_id
386
+ end
387
+ end
388
+ def next_id
389
+ @id_mutex.synchronize do
390
+ ensure_next_id_set
391
+ @next_id += 1
392
+ end
393
+ end
394
+ def reserve_next_id(reserved_id)
395
+ @id_mutex.synchronize do
396
+ ensure_next_id_set
397
+ if @next_id < reserved_id
398
+ @next_id = reserved_id
399
+ else
400
+ raise OdbaDuplicateIdError,
401
+ "The id '#{reserved_id}' has already been assigned"
402
+ end
403
+ end
404
+ end
405
+ def remove_dictionary(language)
406
+ # remove configuration
407
+ self.dbi.do <<-SQL
408
+ DELETE FROM pg_ts_cfg
409
+ WHERE ts_name='default_#{language}'
410
+ SQL
411
+ # remove dictionaries
412
+ self.dbi.do <<-SQL
413
+ DELETE FROM pg_ts_dict
414
+ WHERE dict_name IN ('#{language}_ispell', '#{language}_stem')
415
+ SQL
416
+ # remove tokens
417
+ self.dbi.do <<-SQL
418
+ DELETE FROM pg_ts_cfgmap
419
+ WHERE ts_name='default_#{language}'
420
+ SQL
421
+ end
422
+ def restore(odba_id)
423
+ row = self.dbi.select_one("SELECT content FROM object WHERE odba_id = ?", odba_id)
424
+ row.first unless row.nil?
425
+ end
426
+ def retrieve_connected_objects(target_id)
427
+ sql = <<-SQL
428
+ SELECT origin_id FROM object_connection
429
+ WHERE target_id = ?
430
+ SQL
431
+ self.dbi.select_all(sql, target_id)
432
+ end
433
+ def retrieve_from_condition_index(index_name, conditions, limit=nil)
434
+ sql = <<-EOQ
435
+ SELECT target_id, COUNT(target_id) AS relevance
436
+ FROM #{index_name}
437
+ WHERE TRUE
438
+ EOQ
439
+ values = []
440
+ lines = conditions.collect { |name, info|
441
+ val = nil
442
+ condition = nil
443
+ if(info.is_a?(Hash))
444
+ condition = info['condition']
445
+ if(val = info['value'])
446
+ if(/i?like/i.match(condition))
447
+ val += '%'
448
+ end
449
+ condition = "#{condition || '='} ?"
450
+ values.push(val.to_s)
451
+ end
452
+ elsif(info)
453
+ condition = "= ?"
454
+ values.push(info.to_s)
455
+ end
456
+ sql << <<-EOQ
457
+ AND #{name} #{condition || 'IS NULL'}
458
+ EOQ
459
+ }
460
+ sql << " GROUP BY target_id\n"
461
+ if(limit)
462
+ sql << " LIMIT #{limit}"
463
+ end
464
+ self.dbi.select_all(sql, *values)
465
+ end
466
+ def retrieve_from_fulltext_index(index_name, search_term, dict, limit=nil)
467
+ ## this combination of gsub statements solves the problem of
468
+ # properly escaping strings of this form: "(2:1)" into
469
+ # '\(2\:1\)' (see test_retrieve_from_fulltext_index)
470
+ term = search_term.strip.gsub(/\s+/, '&').gsub(/&+/, '&')\
471
+ .gsub(/[():]/i, '\\ \\&').gsub(/\s/, '')
472
+ sql = <<-EOQ
473
+ SELECT target_id,
474
+ max(ts_rank(search_term, to_tsquery(?, ?))) AS relevance
475
+ FROM #{index_name}
476
+ WHERE search_term @@ to_tsquery(?, ?)
477
+ GROUP BY target_id
478
+ ORDER BY relevance DESC
479
+ EOQ
480
+ if(limit)
481
+ sql << " LIMIT #{limit}"
482
+ end
483
+ self.dbi.select_all(sql, dict, term, dict, term)
484
+ rescue DBI::ProgrammingError => e
485
+ warn("ODBA::Storage.retrieve_from_fulltext_index rescued a DBI::ProgrammingError(#{e.message}). Query:")
486
+ warn("self.dbi.select_all(#{sql}, #{dict}, #{term}, #{dict}, #{term})")
487
+ warn("returning empty result")
488
+ []
489
+ end
490
+ def retrieve_from_index(index_name, search_term,
491
+ exact=nil, limit=nil)
492
+ unless(exact)
493
+ search_term = search_term + "%"
494
+ end
495
+ sql = <<-EOQ
496
+ SELECT target_id, COUNT(target_id) AS relevance
497
+ FROM #{index_name}
498
+ WHERE search_term LIKE ?
499
+ GROUP BY target_id
500
+ EOQ
501
+ if(limit)
502
+ sql << " LIMIT #{limit}"
503
+ end
504
+ self.dbi.select_all(sql, search_term)
505
+ end
506
+ def restore_collection(odba_id)
507
+ self.dbi.select_all <<-EOQ
508
+ SELECT key, value FROM collection WHERE odba_id = #{odba_id}
509
+ EOQ
510
+ end
511
+ def restore_named(name)
512
+ row = self.dbi.select_one("SELECT content FROM object WHERE name = ?",
513
+ name)
514
+ row.first unless row.nil?
515
+ end
516
+ def restore_prefetchable
517
+ self.dbi.select_all <<-EOQ
518
+ SELECT odba_id, content FROM object WHERE prefetchable = true
519
+ EOQ
520
+ end
521
+ def setup
522
+ TABLES.each { |name, definition|
523
+ self.dbi.do(definition) rescue DBI::ProgrammingError
524
+ }
525
+ unless(self.dbi.columns('object').any? { |col| col.name == 'extent' })
526
+ self.dbi.do <<-EOS
527
+ ALTER TABLE object ADD COLUMN extent TEXT;
528
+ CREATE INDEX extent_index ON object(extent);
529
+ EOS
530
+ end
531
+ end
532
+ def store(odba_id, dump, name, prefetchable, klass)
533
+ sql = "SELECT name FROM object WHERE odba_id = ?"
534
+ if(row = self.dbi.select_one(sql, odba_id))
535
+ name ||= row['name']
536
+ self.dbi.do <<-SQL, dump, name, prefetchable, klass.to_s, odba_id
537
+ UPDATE object SET
538
+ content = ?,
539
+ name = ?,
540
+ prefetchable = ?,
541
+ extent = ?
542
+ WHERE odba_id = ?
543
+ SQL
544
+ else
545
+ self.dbi.do <<-SQL, odba_id, dump, name, prefetchable, klass.to_s
546
+ INSERT INTO object (odba_id, content, name, prefetchable, extent)
547
+ VALUES (?, ?, ?, ?, ?)
548
+ SQL
549
+ end
550
+ end
551
+ def transaction(&block)
552
+ dbi = nil
553
+ retval = nil
554
+ @dbi.transaction { |dbi|
555
+ ## this should not be necessary anymore:
556
+ #dbi['AutoCommit'] = false
557
+ Thread.current[:txn] = dbi
558
+ retval = block.call
559
+ }
560
+ retval
561
+ ensure
562
+ ## this should not be necessary anymore:
563
+ #dbi['AutoCommit'] = true
564
+ Thread.current[:txn] = nil
565
+ end
566
+ def update_condition_index(index_name, origin_id, search_terms, target_id)
567
+ keys = []
568
+ vals = []
569
+ search_terms.each { |key, val|
570
+ keys.push(key)
571
+ vals.push(val)
572
+ }
573
+ if(target_id)
574
+ self.dbi.do <<-SQL, origin_id, target_id, *vals
575
+ INSERT INTO #{index_name} (origin_id, target_id, #{keys.join(', ')})
576
+ VALUES (?, ?#{', ?' * keys.size})
577
+ SQL
578
+ else
579
+ key_str = keys.collect { |key| "#{key}=?" }.join(', ')
580
+ self.dbi.do <<-SQL, *(vals.push(origin_id))
581
+ UPDATE #{index_name} SET #{key_str}
582
+ WHERE origin_id = ?
583
+ SQL
584
+ end
585
+ end
586
+ def update_fulltext_index(index_name, origin_id, search_term, target_id, dict)
587
+ search_term = search_term.gsub(/\s+/, ' ').strip
588
+ if(target_id)
589
+ self.dbi.do <<-SQL, origin_id, dict, search_term, target_id
590
+ INSERT INTO #{index_name} (origin_id, search_term, target_id)
591
+ VALUES (?, to_tsvector(?, ?), ?)
592
+ SQL
593
+ else
594
+ self.dbi.do <<-SQL, dict, search_term, origin_id
595
+ UPDATE #{index_name} SET search_term=to_tsvector(?, ?)
596
+ WHERE origin_id=?
597
+ SQL
598
+ end
599
+ end
600
+ def update_index(index_name, origin_id, search_term, target_id)
601
+ if(target_id)
602
+ self.dbi.do <<-SQL, origin_id, search_term, target_id
603
+ INSERT INTO #{index_name} (origin_id, search_term, target_id)
604
+ VALUES (?, ?, ?)
605
+ SQL
606
+ else
607
+ self.dbi.do <<-SQL, search_term, origin_id
608
+ UPDATE #{index_name} SET search_term=?
609
+ WHERE origin_id=?
610
+ SQL
611
+ end
612
+ end
613
+ private
614
+ def ensure_next_id_set
615
+ if(@next_id.nil?)
616
+ @next_id = restore_max_id
617
+ end
618
+ end
619
+ def restore_max_id
620
+ row = self.dbi.select_one("SELECT odba_id FROM object ORDER BY odba_id DESC LIMIT 1")
621
+ unless(row.nil? || row.first.nil?)
622
+ row.first
623
+ else
624
+ 0
625
+ end
626
+ end
627
+ end
628
+ end