activerecord-nuodb-adapter 1.0.0.rc.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,778 @@
1
+ #
2
+ # Copyright (c) 2012, NuoDB, Inc.
3
+ # All rights reserved.
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are met:
7
+ #
8
+ # * Redistributions of source code must retain the above copyright
9
+ # notice, this list of conditions and the following disclaimer.
10
+ # * Redistributions in binary form must reproduce the above copyright
11
+ # notice, this list of conditions and the following disclaimer in the
12
+ # documentation and/or other materials provided with the distribution.
13
+ # * Neither the name of NuoDB, Inc. nor the names of its contributors may
14
+ # be used to endorse or promote products derived from this software
15
+ # without specific prior written permission.
16
+ #
17
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
18
+ # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
19
+ # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
20
+ # DISCLAIMED. IN NO EVENT SHALL NUODB, INC. BE LIABLE FOR ANY DIRECT, INDIRECT,
21
+ # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
+ # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
23
+ # OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
24
+ # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
25
+ # OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
26
+ # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
+ #
28
+
29
+ require 'arel/visitors/nuodb'
30
+ require 'active_record'
31
+ require 'active_record/base'
32
+ require 'active_record/connection_adapters/abstract_adapter'
33
+ require 'active_record/connection_adapters/statement_pool'
34
+ require 'active_record/connection_adapters/nuodb/version'
35
+ require 'arel/visitors/bind_visitor'
36
+ require 'active_support/core_ext/hash/keys'
37
+
38
+ require 'nuodb'
39
+
40
+ module ActiveRecord
41
+
42
+ class Base
43
+
44
+ def self.nuodb_connection(config) #:nodoc:
45
+ config.symbolize_keys!
46
+ unless config[:database]
47
+ raise ArgumentError, "No database file specified. Missing argument: database"
48
+ end
49
+ # supply configuration defaults
50
+ config.reverse_merge! :host => 'localhost'
51
+ ConnectionAdapters::NuoDBAdapter.new nil, logger, nil, config
52
+ end
53
+
54
+ end
55
+
56
+ class LostConnection < WrappedDatabaseException
57
+ end
58
+
59
+ module ConnectionAdapters
60
+
61
+ class NuoDBColumn < Column
62
+
63
+ def initialize(name, default, sql_type = nil, null = true, options = {})
64
+ @options = options.symbolize_keys
65
+ super(name, default, sql_type, null)
66
+ @primary = @options[:is_identity] || @options[:is_primary]
67
+ end
68
+
69
+ class << self
70
+
71
+ def string_to_binary(value)
72
+ "0x#{value.unpack("H*")[0]}"
73
+ end
74
+
75
+ def binary_to_string(value)
76
+ value =~ /[^[:xdigit:]]/ ? value : [value].pack('H*')
77
+ end
78
+
79
+ end
80
+
81
+ def is_identity?
82
+ @options[:is_identity]
83
+ end
84
+
85
+ def is_primary?
86
+ @options[:is_primary]
87
+ end
88
+
89
+ def is_utf8?
90
+ !!(@sql_type =~ /nvarchar|ntext|nchar/i)
91
+ end
92
+
93
+ def is_integer?
94
+ !!(@sql_type =~ /int/i)
95
+ end
96
+
97
+ def is_real?
98
+ !!(@sql_type =~ /real/i)
99
+ end
100
+
101
+ def sql_type_for_statement
102
+ if is_integer? || is_real?
103
+ sql_type.sub(/\((\d+)?\)/, '')
104
+ else
105
+ sql_type
106
+ end
107
+ end
108
+
109
+ def default_function
110
+ @options[:default_function]
111
+ end
112
+
113
+ def table_name
114
+ @options[:table_name]
115
+ end
116
+
117
+ def table_klass
118
+ @table_klass ||= begin
119
+ table_name.classify.constantize
120
+ rescue StandardError, NameError, LoadError
121
+ nil
122
+ end
123
+ (@table_klass && @table_klass < ActiveRecord::Base) ? @table_klass : nil
124
+ end
125
+
126
+ private
127
+
128
+ def extract_limit(sql_type)
129
+ case sql_type
130
+ when /^smallint/i
131
+ 2
132
+ when /^int/i
133
+ 4
134
+ when /^bigint/i
135
+ 8
136
+ when /\(max\)/, /decimal/, /numeric/
137
+ nil
138
+ else
139
+ super
140
+ end
141
+ end
142
+
143
+ def simplified_type(field_type)
144
+ case field_type
145
+ when /real/i then
146
+ :float
147
+ when /money/i then
148
+ :decimal
149
+ when /image/i then
150
+ :binary
151
+ when /bit/i then
152
+ :boolean
153
+ when /uniqueidentifier/i then
154
+ :string
155
+ when /datetime/i then
156
+ :datetime
157
+ when /varchar\(max\)/ then
158
+ :text
159
+ when /timestamp/ then
160
+ :binary
161
+ else
162
+ super
163
+ end
164
+ end
165
+
166
+ end #class NuoDBColumn
167
+
168
+ class NuoDBAdapter < AbstractAdapter
169
+
170
+ class StatementPool < ConnectionAdapters::StatementPool
171
+
172
+ attr_reader :max, :connection
173
+
174
+ def initialize(connection, max)
175
+ super
176
+ @cache = Hash.new { |h, pid| h[pid] = {} }
177
+ end
178
+
179
+ def each(&block)
180
+ cache.each(&block)
181
+ end
182
+
183
+ def key?(key)
184
+ cache.key?(key)
185
+ end
186
+
187
+ def [](key)
188
+ cache[key]
189
+ end
190
+
191
+ def []=(sql, key)
192
+ while max <= cache.size
193
+ dealloc cache.shift.last[:stmt]
194
+ end
195
+ cache[sql] = key
196
+ end
197
+
198
+ def length
199
+ cache.length
200
+ end
201
+
202
+ def delete(key)
203
+ dealloc cache[key][:stmt]
204
+ cache.delete(key)
205
+ end
206
+
207
+ def clear
208
+ cache.each_value do |hash|
209
+ dealloc hash[:stmt]
210
+ end
211
+ cache.clear
212
+ end
213
+
214
+ private
215
+
216
+ def cache
217
+ @cache[Process.pid]
218
+ end
219
+
220
+ def dealloc(stmt)
221
+ stmt.close if connection.ping
222
+ end
223
+ end
224
+
225
+ def process_id
226
+ Process.pid
227
+ end
228
+
229
+ attr_accessor :config, :statements
230
+
231
+ class BindSubstitution < Arel::Visitors::NuoDB
232
+ include Arel::Visitors::BindVisitor
233
+ end
234
+
235
+ def initialize(connection, logger, pool, config)
236
+ super(connection, logger, pool)
237
+ @visitor = Arel::Visitors::NuoDB.new self
238
+ @config = config.clone
239
+ # prefer to run with prepared statements unless otherwise specified
240
+ if @config.fetch(:prepared_statements) { true }
241
+ @visitor = Arel::Visitors::NuoDB.new self
242
+ else
243
+ @visitor = BindSubstitution.new self
244
+ end
245
+ connect!
246
+ end
247
+
248
+ # ABSTRACT ADAPTER #######################################
249
+
250
+ # ADAPTER NAME ===========================================
251
+
252
+ def adapter_name
253
+ 'NuoDB'
254
+ end
255
+
256
+ # FEATURES ===============================================
257
+
258
+ def supports_migrations?
259
+ true
260
+ end
261
+
262
+ def supports_primary_key?
263
+ true
264
+ end
265
+
266
+ def supports_count_distinct?
267
+ true
268
+ end
269
+
270
+ def supports_ddl_transactions?
271
+ true
272
+ end
273
+
274
+ def supports_bulk_alter?
275
+ false
276
+ end
277
+
278
+ def supports_savepoints?
279
+ true
280
+ end
281
+
282
+ def supports_index_sort_order?
283
+ true
284
+ end
285
+
286
+ def supports_partial_index?
287
+ false
288
+ end
289
+
290
+ def supports_explain?
291
+ false
292
+ end
293
+
294
+ # CONNECTION MANAGEMENT ==================================
295
+
296
+ def reconnect!
297
+ disconnect!
298
+ connect!
299
+ super
300
+ end
301
+
302
+ def connect!
303
+ # todo add native method for new and initialize where init takes a hash
304
+ @connection = ::NuoDB::Connection.createSqlConnection(
305
+ "#{config[:database]}@#{config[:host]}", config[:schema],
306
+ config[:username], config[:password])
307
+ @statements = StatementPool.new(@connection, @config.fetch(:statement_limit) { 1000 })
308
+ @quoted_column_names, @quoted_table_names = {}, {}
309
+ end
310
+
311
+ def disconnect!
312
+ super
313
+ clear_cache!
314
+ raw_connection.disconnect rescue nil
315
+ end
316
+
317
+ def reset!
318
+ reconnect!
319
+ end
320
+
321
+ def clear_cache!
322
+ @statements.clear
323
+ @statements = nil
324
+ end
325
+
326
+ # SAVEPOINT SUPPORT ======================================
327
+
328
+ def create_savepoint
329
+ execute("SAVEPOINT #{current_savepoint_name}")
330
+ end
331
+
332
+ def rollback_to_savepoint
333
+ execute("ROLLBACK TO SAVEPOINT #{current_savepoint_name}")
334
+ end
335
+
336
+ def release_savepoint
337
+ execute("RELEASE SAVEPOINT #{current_savepoint_name}")
338
+ end
339
+
340
+ # EXCEPTION TRANSLATION ==================================
341
+
342
+ protected
343
+
344
+ LOST_CONNECTION_MESSAGES = [/remote connection closed/i].freeze
345
+
346
+ def lost_connection_messages
347
+ LOST_CONNECTION_MESSAGES
348
+ end
349
+
350
+ CONNECTION_NOT_ESTABLISHED_MESSAGES = [/can't find broker for database/i, /no .* nodes are available for database/i]
351
+
352
+ def connection_not_established_messages
353
+ CONNECTION_NOT_ESTABLISHED_MESSAGES
354
+ end
355
+
356
+ def translate_exception(exception, message)
357
+ case message
358
+ when /duplicate value in unique index/i
359
+ RecordNotUnique.new(message, exception)
360
+ when /too few values specified in the value list/i
361
+ # defaults to StatementInvalid, so we are okay, but just to be explicit...
362
+ super
363
+ when *lost_connection_messages
364
+ LostConnection.new(message, exception)
365
+ when *connection_not_established_messages
366
+ ConnectionNotEstablished.new(message)
367
+ else
368
+ super
369
+ end
370
+ end
371
+
372
+ # SCHEMA DEFINITIONS #####################################
373
+
374
+ public
375
+
376
+ def primary_key(table_name)
377
+ # n.b. active record does not support composite primary keys!
378
+ row = exec_query(<<-eosql, 'SCHEMA', [config[:schema], table_name.to_s]).rows.first
379
+ SELECT
380
+ indexfields.field as field_name
381
+ FROM
382
+ system.indexfields AS indexfields
383
+ WHERE
384
+ indexfields.schema = ? AND
385
+ indexfields.tablename = ?
386
+ eosql
387
+ row && row.first
388
+ end
389
+
390
+ def version
391
+ self.class::VERSION
392
+ end
393
+
394
+ # SCHEMA STATEMENTS ######################################
395
+
396
+ public
397
+
398
+ def table_exists?(table_name)
399
+ return false unless table_name
400
+
401
+ table_name = table_name.to_s.downcase
402
+ schema, table = table_name.split('.', 2)
403
+
404
+ unless table
405
+ table = schema
406
+ schema = nil
407
+ end
408
+
409
+ tables('SCHEMA', schema).include? table
410
+ end
411
+
412
+ def tables(name = 'SCHEMA', schema = nil)
413
+ result = exec_query(<<-eosql, name, [schema || config[:schema]])
414
+ SELECT
415
+ tablename
416
+ FROM
417
+ system.tables
418
+ WHERE
419
+ schema = ?
420
+ eosql
421
+ result.inject([]) do |tables, row|
422
+ row.symbolize_keys!
423
+ tables << row[:tablename].downcase
424
+ end
425
+ end
426
+
427
+ # Returns an array of indexes for the given table. Skip primary keys.
428
+ def indexes(table_name, name = nil)
429
+
430
+ # the following query returns something like this:
431
+ #
432
+ # INDEXNAME TABLENAME NON_UNIQUE FIELD LENGTH
433
+ # ---------------------- --------- ---------- --------- ------
434
+ # COMPANIES..PRIMARY_KEY COMPANIES 0 ID 4
435
+ # COMPANY_INDEX COMPANIES 1 FIRM_ID 4
436
+ # COMPANY_INDEX COMPANIES 1 TYPE 255
437
+ # COMPANY_INDEX COMPANIES 1 RATING 4
438
+ # COMPANY_INDEX COMPANIES 1 RUBY_TYPE 255
439
+
440
+ result = exec_query(<<-eosql, 'SCHEMA', [config[:schema], table_name.to_s])
441
+ SELECT
442
+ indexes.indexname as index_name,
443
+ indexes.tablename as table_name,
444
+ CASE indexes.indextype WHEN 2 THEN 1 ELSE 0 END AS non_unique,
445
+ indexfields.field as field_name,
446
+ fields.length as field_length
447
+ FROM
448
+ system.indexes AS indexes, system.indexfields AS indexfields, system.fields AS fields
449
+ WHERE
450
+ indexes.schema = ? AND
451
+ indexes.tablename = ? AND
452
+ indexes.indexname = indexfields.indexname AND
453
+ indexfields.field = fields.field AND
454
+ indexfields.schema = fields.schema AND
455
+ indexfields.tablename = fields.tablename
456
+ eosql
457
+ indexes = []
458
+ current_index = nil
459
+ result.map do |row|
460
+ row.symbolize_keys!
461
+ index_name = row[:index_name]
462
+ if current_index != index_name
463
+ next if !!(/PRIMARY/ =~ index_name)
464
+ current_index = index_name
465
+ table_name = row[:table_name]
466
+ is_unique = row[:non_unique].to_i == 1
467
+ indexes << IndexDefinition.new(table_name, index_name, is_unique, [], [], [])
468
+ end
469
+ indexes.last.columns << row[:field_name] unless row[:field_name].nil?
470
+ indexes.last.lengths << row[:field_length] unless row[:field_length].nil?
471
+ end
472
+ indexes
473
+ end
474
+
475
+ def columns(table_name, name = nil)
476
+ sql = 'select field,datatype,length from system.fields where schema = ? and tablename = ?'
477
+ cache = statements[sql] ||= {
478
+ :stmt => raw_connection.prepare(sql)
479
+ }
480
+ stmt = cache[:stmt]
481
+
482
+ schema_name, table_name = split_table_name table_name
483
+ stmt.setString 1, schema_name
484
+ stmt.setString 2, table_name
485
+
486
+ rset = stmt.executeQuery
487
+ cols = []
488
+ while rset.next
489
+ name = rset.getString(1).downcase
490
+ # todo this was unimplemented, fix this mess
491
+ default = nil
492
+ sql_type = nil # TODO should come from query
493
+ null = true # TODO should come from query
494
+ cols << Column.new(name, default, sql_type, null)
495
+ end
496
+ cols
497
+ end
498
+
499
+ # todo implement the remaining schema statements methods: rename columns, tables, etc...
500
+ # todo, and these methods have to clear the cache!!!
501
+
502
+ public
503
+
504
+ def native_database_types
505
+ NATIVE_DATABASE_TYPES
506
+ end
507
+
508
+ def type_to_sql(type, limit = nil, precision = nil, scale = nil)
509
+ case type.to_s
510
+ when 'integer'
511
+ return 'integer' unless limit
512
+ case limit
513
+ when 1, 2
514
+ 'smallint'
515
+ when 3, 4
516
+ 'integer'
517
+ when 5..8
518
+ 'bigint'
519
+ else
520
+ raise(ActiveRecordError, "No integer type has byte size #{limit}. Use a numeric with precision 0 instead.")
521
+ end
522
+ else
523
+ super
524
+ end
525
+ end
526
+
527
+ private
528
+
529
+ NATIVE_DATABASE_TYPES = {
530
+ :primary_key => 'int not null generated by default as identity primary key',
531
+ :string => {:name => 'varchar', :limit => 255},
532
+ :text => {:name => 'varchar', :limit => 255},
533
+ :integer => {:name => 'integer'},
534
+ :float => {:name => 'float', :limit => 8},
535
+ :decimal => {:name => 'decimal'},
536
+ :datetime => {:name => 'datetime'},
537
+ :timestamp => {:name => 'datetime'},
538
+ :time => {:name => 'time'},
539
+ :date => {:name => 'date'},
540
+ :binary => {:name => 'binary'},
541
+ :boolean => {:name => 'boolean'},
542
+ :char => {:name => 'char'},
543
+ :varchar_max => {:name => 'varchar(max)'},
544
+ :nchar => {:name => 'nchar'},
545
+ :nvarchar => {:name => 'nvarchar', :limit => 255},
546
+ :nvarchar_max => {:name => 'nvarchar(max)'},
547
+ :ntext => {:name => 'ntext', :limit => 255},
548
+ :ss_timestamp => {:name => 'timestamp'}
549
+ }
550
+
551
+ def split_table_name(table)
552
+ name_parts = table.split '.'
553
+ case name_parts.length
554
+ when 1
555
+ schema_name = raw_connection.getSchema
556
+ table_name = name_parts[0]
557
+ when 2
558
+ schema_name = name_parts[0]
559
+ table_name = name_parts[1]
560
+ else
561
+ raise "Invalid table name: #{table}"
562
+ end
563
+ [schema_name, table_name]
564
+ end
565
+
566
+ # QUOTING ################################################
567
+
568
+ public
569
+
570
+ def quote_column_name(name)
571
+ @quoted_column_names[name] ||= "`#{name.to_s.gsub('`', '``')}`"
572
+ end
573
+
574
+ def quote_table_name(name)
575
+ @quoted_table_names[name] ||= quote_column_name(name).gsub('.', '`.`')
576
+ end
577
+
578
+ def quoted_true
579
+ "'true'"
580
+ end
581
+
582
+ def quoted_false
583
+ "'false'"
584
+ end
585
+
586
+ # DATABASE STATEMENTS ####################################
587
+
588
+ public
589
+
590
+ def outside_transaction?
591
+ false
592
+ end
593
+
594
+ def supports_statement_cache?
595
+ true
596
+ end
597
+
598
+ # Begins the transaction (and turns off auto-committing).
599
+ def begin_db_transaction()
600
+ log('begin transaction', nil) {
601
+ raw_connection.autocommit = false if raw_connection.autocommit?
602
+ }
603
+ end
604
+
605
+ # Commits the transaction (and turns on auto-committing).
606
+ def commit_db_transaction()
607
+ log('commit transaction', nil) {
608
+ raw_connection.autocommit = true unless raw_connection.autocommit?
609
+ raw_connection.commit
610
+ }
611
+ end
612
+
613
+ # Rolls back the transaction (and turns on auto-committing). Must be
614
+ # done if the transaction block raises an exception or returns false.
615
+ def rollback_db_transaction()
616
+ log('rollback transaction', nil) {
617
+ raw_connection.autocommit = true unless raw_connection.autocommit?
618
+ raw_connection.rollback
619
+ }
620
+ end
621
+
622
+ def execute(sql, name = 'SQL')
623
+ log(sql, name) do
624
+ cache = statements[process_id] ||= {
625
+ :stmt => raw_connection.createStatement
626
+ }
627
+ stmt = cache[:stmt]
628
+ stmt.execute(sql)
629
+ end
630
+ end
631
+
632
+ def exec_query(sql, name = 'SQL', binds = [])
633
+ log(sql, name, binds) do
634
+ if binds.empty?
635
+
636
+ cache = statements[process_id] ||= {
637
+ :stmt => raw_connection.createStatement
638
+ }
639
+ stmt = cache[:stmt]
640
+
641
+ stmt.execute(sql)
642
+ genkeys = stmt.getGeneratedKeys
643
+ row = genkeys ? next_row(genkeys) : nil
644
+ @last_inserted_id = row ? row[0] : nil
645
+ result = stmt.getResultSet
646
+ if result
647
+ names = column_names result
648
+ rows = all_rows result
649
+ ActiveRecord::Result.new(names, rows)
650
+ else
651
+ nil
652
+ end
653
+
654
+ else
655
+
656
+ cache = statements[sql] ||= {
657
+ :stmt => raw_connection.prepare(sql)
658
+ }
659
+ stmt = cache[:stmt]
660
+ binds.to_enum.with_index(1).each { |bind, column|
661
+ value = bind[1]
662
+ case value
663
+ when String
664
+ stmt.setString column, value
665
+ when Integer
666
+ stmt.setInteger column, value
667
+ when Fixnum
668
+ stmt.setInteger column, value
669
+ when Float
670
+ stmt.setDouble column, value
671
+ when TrueClass
672
+ stmt.setBoolean column, true
673
+ when FalseClass
674
+ stmt.setBoolean column, false
675
+ when Time
676
+ stmt.setTime column, value
677
+ else
678
+ raise "don't know how to bind #{value.class} to parameter #{column}"
679
+ end
680
+ }
681
+
682
+ stmt.execute
683
+
684
+ genkeys = stmt.getGeneratedKeys
685
+ row = genkeys ? next_row(genkeys) : nil
686
+ @last_inserted_id = row ? row[0] : nil
687
+
688
+ result = stmt.getResultSet
689
+
690
+ if result
691
+ names = column_names result
692
+ rows = all_rows result
693
+ ActiveRecord::Result.new(names, rows)
694
+ else
695
+ nil
696
+ end
697
+
698
+ end
699
+ end
700
+ end
701
+
702
+ def last_inserted_id(result)
703
+ @last_inserted_id
704
+ end
705
+
706
+ protected
707
+
708
+ def select(sql, name = nil, binds = [])
709
+ exec_query(sql, name, binds).to_a
710
+ end
711
+
712
+ protected
713
+
714
+ def select_rows(sql, name = nil)
715
+ rows = exec_query(sql, name).rows
716
+ end
717
+
718
+ private
719
+
720
+ def column_names (result)
721
+ return [] if result.nil?
722
+ names = []
723
+ meta = result.getMetaData
724
+ count = meta.getColumnCount
725
+ (1..count).each { |i|
726
+ names << meta.getColumnName(i).downcase
727
+ }
728
+ names
729
+ end
730
+
731
+ def all_rows(result)
732
+ rows = []
733
+ while (row = next_row(result)) != nil
734
+ rows << row
735
+ end
736
+ rows
737
+ end
738
+
739
+ def next_row(result)
740
+ return nil if result.nil?
741
+ if result.next
742
+ meta = result.getMetaData
743
+ count = meta.getColumnCount
744
+ row = []
745
+ (1..count).each { |i|
746
+ type = meta.getType(i)
747
+ case type
748
+ when :SQL_INTEGER
749
+ row << result.getInteger(i)
750
+ when :SQL_DOUBLE
751
+ row << result.getDouble(i)
752
+ when :SQL_STRING
753
+ row << result.getString(i)
754
+ when :SQL_DATE
755
+ row << result.getDate(i)
756
+ when :SQL_TIME
757
+ row << result.getTime(i)
758
+ when :SQL_TIMESTAMP
759
+ row << result.getTimestamp(i)
760
+ when :SQL_CHAR
761
+ row << result.getChar(i)
762
+ when :SQL_BOOLEAN
763
+ row << result.getBoolean(i)
764
+ else
765
+ raise "unknown type #{type} for column #{i}"
766
+ end
767
+ }
768
+ row
769
+ else
770
+ nil
771
+ end
772
+ end
773
+
774
+ end
775
+
776
+ end
777
+
778
+ end