elasticsearch_record 1.0.2 → 1.1.0

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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +4 -0
  3. data/Gemfile.lock +10 -14
  4. data/README.md +180 -27
  5. data/docs/CHANGELOG.md +36 -18
  6. data/docs/LICENSE.txt +1 -1
  7. data/elasticsearch_record.gemspec +42 -0
  8. data/lib/active_record/connection_adapters/elasticsearch/column.rb +20 -6
  9. data/lib/active_record/connection_adapters/elasticsearch/database_statements.rb +142 -125
  10. data/lib/active_record/connection_adapters/elasticsearch/quoting.rb +2 -23
  11. data/lib/active_record/connection_adapters/elasticsearch/schema_creation.rb +30 -0
  12. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/attribute_methods.rb +103 -0
  13. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/column_methods.rb +42 -0
  14. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/create_table_definition.rb +158 -0
  15. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_alias_definition.rb +32 -0
  16. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_definition.rb +132 -0
  17. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_mapping_definition.rb +110 -0
  18. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/table_setting_definition.rb +136 -0
  19. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions/update_table_definition.rb +174 -0
  20. data/lib/active_record/connection_adapters/elasticsearch/schema_definitions.rb +37 -0
  21. data/lib/active_record/connection_adapters/elasticsearch/schema_dumper.rb +110 -0
  22. data/lib/active_record/connection_adapters/elasticsearch/schema_statements.rb +398 -174
  23. data/lib/active_record/connection_adapters/elasticsearch/table_statements.rb +232 -0
  24. data/lib/active_record/connection_adapters/elasticsearch/type/multicast_value.rb +2 -0
  25. data/lib/active_record/connection_adapters/elasticsearch/unsupported_implementation.rb +32 -0
  26. data/lib/active_record/connection_adapters/elasticsearch_adapter.rb +112 -19
  27. data/lib/arel/collectors/elasticsearch_query.rb +0 -1
  28. data/lib/arel/visitors/elasticsearch.rb +7 -579
  29. data/lib/arel/visitors/elasticsearch_base.rb +234 -0
  30. data/lib/arel/visitors/elasticsearch_query.rb +463 -0
  31. data/lib/arel/visitors/elasticsearch_schema.rb +124 -0
  32. data/lib/elasticsearch_record/core.rb +44 -10
  33. data/lib/elasticsearch_record/errors.rb +13 -0
  34. data/lib/elasticsearch_record/gem_version.rb +6 -2
  35. data/lib/elasticsearch_record/instrumentation/log_subscriber.rb +27 -9
  36. data/lib/elasticsearch_record/model_schema.rb +5 -0
  37. data/lib/elasticsearch_record/persistence.rb +31 -26
  38. data/lib/elasticsearch_record/query.rb +56 -17
  39. data/lib/elasticsearch_record/querying.rb +17 -0
  40. data/lib/elasticsearch_record/relation/calculation_methods.rb +3 -0
  41. data/lib/elasticsearch_record/relation/core_methods.rb +57 -17
  42. data/lib/elasticsearch_record/relation/query_clause_tree.rb +38 -1
  43. data/lib/elasticsearch_record/relation/query_methods.rb +6 -0
  44. data/lib/elasticsearch_record/relation/result_methods.rb +15 -9
  45. data/lib/elasticsearch_record/result.rb +32 -5
  46. data/lib/elasticsearch_record/statement_cache.rb +2 -1
  47. data/lib/elasticsearch_record.rb +2 -2
  48. metadata +29 -11
  49. data/.ruby-version +0 -1
  50. data/lib/elasticsearch_record/schema_migration.rb +0 -30
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module Elasticsearch
6
+ # extend adapter with table-related statements
7
+ module TableStatements
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ # ORIGINAL methods untouched:
12
+ #
13
+ # SUPPORTED but not used:
14
+ #
15
+ # UNSUPPORTED methods that will be ignored:
16
+ # - native_database_types
17
+ # - table_options
18
+ # - table_comment
19
+ # - table_alias_for
20
+ #
21
+ # UNSUPPORTED methods that will fail:
22
+ # - create_join_table
23
+ # - drop_join_table
24
+ # - create_alter_table
25
+ # - change_column_default
26
+ # - change_column_null
27
+ # - rename_column
28
+ #
29
+ # UPCOMING future methods:
30
+ # - clone (option -> close, or read-only (#lock / unlock) )
31
+ # - refresh
32
+ # - rename_table
33
+
34
+ define_unsupported_method :rename_table
35
+
36
+ # Opens a closed index.
37
+ # @param [String] table_name
38
+ # @return [Boolean] acknowledged status
39
+ def open_table(table_name)
40
+ schema_cache.clear_data_source_cache!(table_name.to_s)
41
+ api(:indices, :open, { index: table_name }, 'OPEN TABLE').dig('acknowledged')
42
+ end
43
+
44
+ # Opens closed indices.
45
+ # @param [Array] table_names
46
+ # @return [Array] acknowledged status for each provided table
47
+ def open_tables(*table_names)
48
+ table_names -= [schema_migration.table_name, InternalMetadata.table_name]
49
+ return if table_names.empty?
50
+
51
+ table_names.map { |table_name| open_table(table_name) }
52
+ end
53
+
54
+ # Closes an index.
55
+ # @param [String] table_name
56
+ # @return [Boolean] acknowledged status
57
+ def close_table(table_name)
58
+ schema_cache.clear_data_source_cache!(table_name.to_s)
59
+ api(:indices, :close, { index: table_name }, 'CLOSE TABLE').dig('acknowledged')
60
+ end
61
+
62
+ # Closes indices by provided names.
63
+ # @param [Array] table_names
64
+ # @return [Array] acknowledged status for each provided table
65
+ def close_tables(*table_names)
66
+ table_names -= [schema_migration.table_name, InternalMetadata.table_name]
67
+ return if table_names.empty?
68
+
69
+ table_names.map { |table_name| close_table(table_name) }
70
+ end
71
+
72
+ # truncates index by provided name.
73
+ # HINT: Elasticsearch does not have a +truncate+ concept:
74
+ # - so we have to store the current index' schema
75
+ # - drop the index
76
+ # - and create it again
77
+ # @param [String] table_name
78
+ # @return [Boolean] acknowledged status
79
+ def truncate_table(table_name)
80
+ # force: automatically drops an existing index
81
+ create_table(table_name, force: true, **table_schema(table_name))
82
+ end
83
+
84
+ alias :truncate :truncate_table
85
+
86
+ # truncate indices by provided names.
87
+ # @param [Array] table_names
88
+ # @return [Array] acknowledged status for each provided table
89
+ def truncate_tables(*table_names)
90
+ table_names -= [schema_migration.table_name, InternalMetadata.table_name]
91
+ return if table_names.empty?
92
+
93
+ table_names.map { |table_name| truncate_table(table_name) }
94
+ end
95
+
96
+ # drops an index
97
+ # [<tt>:if_exists</tt>]
98
+ # Set to +true+ to only drop the table if it exists.
99
+ # Defaults to false.
100
+ # @param [String] table_name
101
+ # @param [Hash] options
102
+ # @return [Array] acknowledged status for provided table
103
+ def drop_table(table_name, **options)
104
+ schema_cache.clear_data_source_cache!(table_name.to_s)
105
+ api(:indices, :delete, { index: table_name, ignore: (options[:if_exists] ? 404 : nil) }, 'DROP TABLE').dig('acknowledged')
106
+ end
107
+
108
+ # creates a new table (index).
109
+ # [<tt>:force</tt>]
110
+ # Set to +true+ to drop an existing table
111
+ # Defaults to false.
112
+ # [<tt>:copy_from</tt>]
113
+ # Set to an existing index, to copy it's schema.
114
+ # [<tt>:if_not_exists</tt>]
115
+ # Set to +true+ to skip creation if table already exists.
116
+ # Defaults to false.
117
+ # @param [String] table_name
118
+ # @param [Boolean] force - force a drop on the existing table (default: false)
119
+ # @param [nil, String] copy_from - copy schema from existing table
120
+ # @param [Hash] options
121
+ # @return [Boolean] acknowledged status
122
+ def create_table(table_name, force: false, copy_from: nil, if_not_exists: false, **options)
123
+ return if if_not_exists && table_exists?(table_name)
124
+
125
+ # copy schema from existing table
126
+ options.merge!(table_schema(copy_from)) if copy_from
127
+
128
+ # create new definition
129
+ definition = create_table_definition(table_name, **extract_table_options!(options))
130
+
131
+ # yield optional block
132
+ if block_given?
133
+ definition.assign do |d|
134
+ yield d
135
+ end
136
+ end
137
+
138
+ # force drop existing table
139
+ if force
140
+ drop_table(table_name, if_exists: true)
141
+ else
142
+ schema_cache.clear_data_source_cache!(table_name.to_s)
143
+ end
144
+
145
+ # execute definition query(ies)
146
+ definition.exec!
147
+ end
148
+
149
+ # A block for changing mappings, settings & aliases in +table+.
150
+ #
151
+ # # change_table() yields a ChangeTableDefinition instance
152
+ # change_table(:suppliers) do |t|
153
+ # t.mapping :name, :string
154
+ # # Other column alterations here
155
+ # end
156
+ def change_table(table_name, **options)
157
+ definition = update_table_definition(table_name, self, **options)
158
+
159
+ # yield optional block
160
+ if block_given?
161
+ definition.assign do |d|
162
+ yield d
163
+ end
164
+ end
165
+
166
+ # execute definition query(ies)
167
+ definition.exec!
168
+ end
169
+
170
+ # -- mapping -------------------------------------------------------------------------------------------------
171
+
172
+ def add_mapping(table_name, name, type, **options, &block)
173
+ _exec_change_table_with(:add_mapping, table_name, name, type, **options, &block)
174
+ end
175
+
176
+ alias :add_column :add_mapping
177
+
178
+ def change_mapping(table_name, name, type, **options, &block)
179
+ _exec_change_table_with(:change_mapping, table_name, name, type, **options, &block)
180
+ end
181
+
182
+ alias :change_column :change_mapping
183
+
184
+ def change_mapping_meta(table_name, name, **options)
185
+ _exec_change_table_with(:change_mapping_meta, table_name, name, **options)
186
+ end
187
+
188
+ def change_mapping_attributes(table_name, name, **options,&block)
189
+ _exec_change_table_with(:change_mapping_attributes, table_name, name, **options, &block)
190
+ end
191
+ alias :change_mapping_attribute :change_mapping_attributes
192
+
193
+ # -- setting -------------------------------------------------------------------------------------------------
194
+
195
+ def add_setting(table_name, name, value, **options, &block)
196
+ _exec_change_table_with(:add_setting, table_name, name, value, **options, &block)
197
+ end
198
+
199
+ def change_setting(table_name, name, value, **options, &block)
200
+ _exec_change_table_with(:change_setting, table_name, name, value, **options, &block)
201
+ end
202
+
203
+ def delete_setting(table_name, name, **options, &block)
204
+ _exec_change_table_with(:delete_setting, table_name, name, **options, &block)
205
+ end
206
+
207
+ # -- alias ---------------------------------------------------------------------------------------------------
208
+
209
+ def add_alias(table_name, name, **options, &block)
210
+ _exec_change_table_with(:add_alias, table_name, name, **options, &block)
211
+ end
212
+
213
+ def change_alias(table_name, name, **options, &block)
214
+ _exec_change_table_with(:change_alias, table_name, name, **options, &block)
215
+ end
216
+
217
+ def delete_alias(table_name, name, **options, &block)
218
+ _exec_change_table_with(:delete_alias, table_name, name, **options, &block)
219
+ end
220
+
221
+ private
222
+
223
+ def _exec_change_table_with(method, table_name, *args, **kwargs, &block)
224
+ change_table(table_name) do |t|
225
+ t.send(method, *args, **kwargs, &block)
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
@@ -4,6 +4,8 @@ module ActiveRecord
4
4
  module Type # :nodoc:
5
5
  class MulticastValue < ActiveRecord::Type::Value
6
6
 
7
+ delegate :user_input_in_time_zone, to: :nested_type
8
+
7
9
  attr_reader :nested_type
8
10
 
9
11
  def initialize(nested_type: nil, **)
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module Elasticsearch
6
+
7
+ class UnsupportedImplementationError < StandardError
8
+ def initialize(method_name)
9
+ super "Unsupported implementation of method: #{method_name}."
10
+ end
11
+ end
12
+
13
+ module UnsupportedImplementation
14
+ extend ActiveSupport::Concern
15
+
16
+ class_methods do
17
+ def define_unsupported_method(*method_names)
18
+ method_names.each do |method_name|
19
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
20
+ def #{method_name}(*args)
21
+ raise NotImplementedError, "'##{method_name}' is originally defined by 'ActiveRecord::ConnectionAdapters' but is not supported by Elasticsearch. Choose a different solution to prevent the execution of this method!"
22
+ end
23
+ RUBY
24
+ end
25
+ end
26
+
27
+ private :define_unsupported_method
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -2,12 +2,17 @@
2
2
 
3
3
  require 'active_record/connection_adapters'
4
4
 
5
- # new
5
+ require 'active_record/connection_adapters/elasticsearch/unsupported_implementation'
6
+
6
7
  require 'active_record/connection_adapters/elasticsearch/column'
7
8
  require 'active_record/connection_adapters/elasticsearch/database_statements'
8
9
  require 'active_record/connection_adapters/elasticsearch/quoting'
10
+ require 'active_record/connection_adapters/elasticsearch/schema_creation'
11
+ require 'active_record/connection_adapters/elasticsearch/schema_definitions'
12
+ require 'active_record/connection_adapters/elasticsearch/schema_dumper'
9
13
  require 'active_record/connection_adapters/elasticsearch/schema_statements'
10
14
  require 'active_record/connection_adapters/elasticsearch/type'
15
+ require 'active_record/connection_adapters/elasticsearch/table_statements'
11
16
 
12
17
  require 'arel/visitors/elasticsearch'
13
18
  require 'arel/collectors/elasticsearch_query'
@@ -40,15 +45,17 @@ module ActiveRecord # :nodoc:
40
45
 
41
46
  # defines the Elasticsearch 'base' structure, which is always included but cannot be resolved through mappings ...
42
47
  BASE_STRUCTURE = [
43
- { 'name' => '_id', 'type' => 'string', 'null' => false, 'primary' => true },
44
- { 'name' => '_index', 'type' => 'string', 'null' => false, 'virtual' => true },
45
- { 'name' => '_score', 'type' => 'float', 'null' => false, 'virtual' => true },
46
- { 'name' => '_type', 'type' => 'string', 'null' => false, 'virtual' => true }
48
+ { 'name' => '_id', 'type' => 'keyword', 'virtual' => true, 'meta' => { 'primary_key' => 'true' } },
49
+ { 'name' => '_index', 'type' => 'keyword', 'virtual' => true },
50
+ { 'name' => '_score', 'type' => 'float', 'virtual' => true },
51
+ { 'name' => '_type', 'type' => 'keyword', 'virtual' => true }
47
52
  ].freeze
48
53
 
54
+ include Elasticsearch::UnsupportedImplementation
49
55
  include Elasticsearch::Quoting
50
- include Elasticsearch::SchemaStatements
51
56
  include Elasticsearch::DatabaseStatements
57
+ include Elasticsearch::SchemaStatements
58
+ include Elasticsearch::TableStatements
52
59
 
53
60
  class << self
54
61
  def base_structure_keys
@@ -58,8 +65,10 @@ module ActiveRecord # :nodoc:
58
65
  def new_client(config)
59
66
  # IMPORTANT: remove +adapter+ from config - otherwise we mess up with Faraday::AdapterRegistry
60
67
  client = ::Elasticsearch::Client.new(config.except(:adapter))
61
- client.ping
68
+ client.ping unless config[:ping] == false
62
69
  client
70
+ rescue ::Elastic::Transport::Transport::Errors::Unauthorized
71
+ raise ActiveRecord::DatabaseConnectionError.username_error(config[:username])
63
72
  rescue ::Elastic::Transport::Transport::ServerError => error
64
73
  raise ::ActiveRecord::ConnectionNotEstablished, error.message
65
74
  end
@@ -123,6 +132,18 @@ module ActiveRecord # :nodoc:
123
132
  # reinitialize the constant with new types
124
133
  TYPE_MAP = ActiveRecord::Type::HashLookupTypeMap.new.tap { |m| initialize_type_map(m) }
125
134
 
135
+ # define native types - which will be used for schema-dumping
136
+ NATIVE_DATABASE_TYPES = {
137
+ primary_key: { name: 'long' },
138
+ string: { name: 'keyword' },
139
+ blob: { name: 'binary' },
140
+ datetime: { name: 'date' },
141
+ bigint: { name: 'long' },
142
+ json: { name: 'object' }
143
+ }.merge(
144
+ TYPE_MAP.keys.map { |key| [key.to_sym, { name: key }] }.to_h
145
+ )
146
+
126
147
  def initialize(*args)
127
148
  super(*args)
128
149
 
@@ -131,20 +152,78 @@ module ActiveRecord # :nodoc:
131
152
  @prepared_statements = false
132
153
  end
133
154
 
134
- def migrations_paths # :nodoc:
155
+ # overwrite method to provide a Elasticsearch path
156
+ def migrations_paths
135
157
  @config[:migrations_paths] || ['db/migrate_elasticsearch']
136
158
  end
137
159
 
138
- # temporary workaround
139
- # toDo: fixme
160
+ # Does this adapter support explain?
161
+ def supports_explain?
162
+ false
163
+ end
164
+
165
+ # Does this adapter support creating indexes in the same statement as
166
+ # creating the table?
167
+ def supports_indexes_in_create?
168
+ false
169
+ end
170
+
171
+ # Does this adapter support metadata comments on database objects (tables)?
172
+ # PLEASE NOTE: Elasticsearch does only support comments on mappings as 'meta' information.
173
+ # This method only relies to create comments on tables (indices) and is therefore not supported.
174
+ # see @ ActiveRecord::ConnectionAdapters::SchemaStatements#create_table
175
+ def supports_comments?
176
+ false
177
+ end
178
+
179
+ # Can comments for tables, columns, and indexes be specified in create/alter table statements?
180
+ # see @ ActiveRecord::ConnectionAdapters::ElasticsearchAdapter#supports_comments?
181
+ def supports_comments_in_create?
182
+ false
183
+ end
184
+
185
+ # disable metadata tables
140
186
  def use_metadata_table? # :nodoc:
141
187
  false
142
188
  end
143
189
 
144
- # temporary workaround
145
- # toDo: fixme
146
- def schema_migration # :nodoc:
147
- @schema_migration ||= ElasticsearchRecord::SchemaMigration
190
+ # returns a hash of 'ActiveRecord types' -> 'Elasticsearch types' (defined @ +NATIVE_DATABASE_TYPES+)
191
+ # @return [Hash]
192
+ def native_database_types # :nodoc:
193
+ NATIVE_DATABASE_TYPES
194
+ end
195
+
196
+ # calls the +elasticsearch-api+ endpoints by provided namespace and action.
197
+ # if a block was provided it'll yield the response.body and returns the blocks result.
198
+ # otherwise it will return the response itself...
199
+ # @param [Symbol] namespace - the API namespace (e.g. indices, nodes, sql, ...)
200
+ # @param [Symbol] action - the API action to call in tha namespace
201
+ # @param [Hash] arguments - action arguments
202
+ # @param [String (frozen)] name - the logging name
203
+ # @param [Boolean] async - send async (default: false) - currently not supported
204
+ # @return [Elasticsearch::API::Response, Object]
205
+ def api(namespace, action, arguments = {}, name = 'API', async: false)
206
+ raise ::StandardError, 'ASYNC api calls are not supported' if async
207
+
208
+ # resolve the API target
209
+ target = namespace == :core ? @connection : @connection.__send__(namespace)
210
+
211
+ log("#{namespace}.#{action}", arguments, name, async: async) do
212
+ response = ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
213
+ target.__send__(action, arguments)
214
+ end
215
+
216
+ if response.is_a?(::Elasticsearch::API::Response)
217
+ # reverse information for the LogSubscriber - shows the 'query-time' in the logs
218
+ # this works, since we use a referenced hash ...
219
+ arguments[:_qt] = response['took']
220
+
221
+ # raise timeouts
222
+ raise(ActiveRecord::StatementTimeout, "Elasticsearch api request failed due a timeout") if response['timed_out']
223
+ end
224
+
225
+ response
226
+ end
148
227
  end
149
228
 
150
229
  private
@@ -156,7 +235,22 @@ module ActiveRecord # :nodoc:
156
235
  # catch Elasticsearch Transport-errors to be treated as +StatementInvalid+ (the original message is still readable ...)
157
236
  def translate_exception(exception, message:, sql:, binds:)
158
237
  case exception
159
- when Elastic::Transport::Transport::ServerError
238
+ when ::Elastic::Transport::Transport::Errors::ClientClosedRequest
239
+ ::ActiveRecord::QueryCanceled.new(message, sql: sql, binds: binds)
240
+ when ::Elastic::Transport::Transport::Errors::RequestTimeout
241
+ ::ActiveRecord::StatementTimeout.new(message, sql: sql, binds: binds)
242
+ when ::Elastic::Transport::Transport::Errors::Conflict
243
+ ::ActiveRecord::RecordNotUnique.new(message, sql: sql, binds: binds)
244
+ when ::Elastic::Transport::Transport::Errors::BadRequest
245
+ if exception.message.match?(/resource_already_exists_exception/)
246
+ ::ActiveRecord::DatabaseAlreadyExists.new(message, sql: sql, binds: binds)
247
+ else
248
+ ::ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
249
+ end
250
+ when ::Elastic::Transport::Transport::Errors::Unauthorized
251
+ ::ActiveRecord::DatabaseConnectionError.username_error(@config[:username])
252
+ # must be last 'Elastic' error
253
+ when ::Elastic::Transport::Transport::ServerError
160
254
  ::ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
161
255
  else
162
256
  # just forward the exception ...
@@ -181,10 +275,10 @@ module ActiveRecord # :nodoc:
181
275
  # returns a new collector for the Arel visitor.
182
276
  # @return [Arel::Collectors::ElasticsearchQuery]
183
277
  def collector
184
- # IMPORTANT: since prepared statements doesn't make sense for elasticsearch,
185
- # we don't have to check for +prepared_statements+ here.
278
+ # IMPORTANT: prepared statements doesn't make sense for elasticsearch,
279
+ # so we don't have to check for +prepared_statements+ here.
186
280
  # Also, bindings are (currently) not supported.
187
- # So, we just need a query collector...
281
+ # So, we just need a single, simple query collector...
188
282
  Arel::Collectors::ElasticsearchQuery.new
189
283
  end
190
284
 
@@ -195,7 +289,6 @@ module ActiveRecord # :nodoc:
195
289
  end
196
290
 
197
291
  # Builds the result object.
198
- #
199
292
  # This is an internal hook to make possible connection adapters to build
200
293
  # custom result objects with response-specific data.
201
294
  # @return [ElasticsearchRecord::Result]
@@ -60,7 +60,6 @@ module Arel # :nodoc: all
60
60
  end
61
61
 
62
62
  # used by the +Arel::Visitors::Elasticsearch#compile+ method (and default Arel visitors)
63
- # todo: maybe return arguments with :_meta information instead of self ...
64
63
  def value
65
64
  self
66
65
  end