activerecord-import 0.17.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 (52) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +1 -0
  3. data/.travis.yml +40 -23
  4. data/CHANGELOG.md +315 -1
  5. data/Gemfile +23 -13
  6. data/LICENSE +21 -56
  7. data/README.markdown +564 -33
  8. data/Rakefile +2 -1
  9. data/activerecord-import.gemspec +3 -3
  10. data/benchmarks/lib/cli_parser.rb +2 -1
  11. data/benchmarks/schema/{mysql_schema.rb → mysql2_schema.rb} +0 -0
  12. data/gemfiles/5.1.gemfile +2 -0
  13. data/gemfiles/5.2.gemfile +2 -0
  14. data/gemfiles/6.0.gemfile +2 -0
  15. data/gemfiles/6.1.gemfile +1 -0
  16. data/lib/activerecord-import.rb +2 -15
  17. data/lib/activerecord-import/adapters/abstract_adapter.rb +9 -3
  18. data/lib/activerecord-import/adapters/mysql_adapter.rb +17 -11
  19. data/lib/activerecord-import/adapters/postgresql_adapter.rb +68 -20
  20. data/lib/activerecord-import/adapters/sqlite3_adapter.rb +128 -9
  21. data/lib/activerecord-import/base.rb +12 -7
  22. data/lib/activerecord-import/import.rb +514 -166
  23. data/lib/activerecord-import/synchronize.rb +2 -2
  24. data/lib/activerecord-import/value_sets_parser.rb +16 -0
  25. data/lib/activerecord-import/version.rb +1 -1
  26. data/test/adapters/makara_postgis.rb +1 -0
  27. data/test/import_test.rb +274 -23
  28. data/test/makara_postgis/import_test.rb +8 -0
  29. data/test/models/account.rb +3 -0
  30. data/test/models/animal.rb +6 -0
  31. data/test/models/bike_maker.rb +7 -0
  32. data/test/models/tag.rb +1 -1
  33. data/test/models/topic.rb +14 -0
  34. data/test/models/user.rb +3 -0
  35. data/test/models/user_token.rb +4 -0
  36. data/test/schema/generic_schema.rb +30 -8
  37. data/test/schema/mysql2_schema.rb +19 -0
  38. data/test/schema/postgresql_schema.rb +18 -0
  39. data/test/schema/sqlite3_schema.rb +13 -0
  40. data/test/support/factories.rb +9 -8
  41. data/test/support/generate.rb +6 -6
  42. data/test/support/mysql/import_examples.rb +14 -2
  43. data/test/support/postgresql/import_examples.rb +220 -1
  44. data/test/support/shared_examples/on_duplicate_key_ignore.rb +15 -9
  45. data/test/support/shared_examples/on_duplicate_key_update.rb +271 -8
  46. data/test/support/shared_examples/recursive_import.rb +91 -21
  47. data/test/support/sqlite3/import_examples.rb +189 -25
  48. data/test/synchronize_test.rb +8 -0
  49. data/test/test_helper.rb +24 -3
  50. data/test/value_sets_bytes_parser_test.rb +13 -2
  51. metadata +32 -13
  52. data/test/schema/mysql_schema.rb +0 -16
@@ -1,23 +1,12 @@
1
1
  # encoding: UTF-8
2
2
  def should_support_sqlite3_import_functionality
3
- should_support_on_duplicate_key_ignore
3
+ if ActiveRecord::Base.connection.supports_on_duplicate_key_update?
4
+ should_support_sqlite_upsert_functionality
5
+ end
4
6
 
5
7
  describe "#supports_imports?" do
6
- context "and SQLite is 3.7.11 or higher" do
7
- it "supports import" do
8
- version = ActiveRecord::ConnectionAdapters::SQLite3Adapter::Version.new("3.7.11")
9
- assert ActiveRecord::Base.supports_import?(version)
10
-
11
- version = ActiveRecord::ConnectionAdapters::SQLite3Adapter::Version.new("3.7.12")
12
- assert ActiveRecord::Base.supports_import?(version)
13
- end
14
- end
15
-
16
- context "and SQLite less than 3.7.11" do
17
- it "doesn't support import" do
18
- version = ActiveRecord::ConnectionAdapters::SQLite3Adapter::Version.new("3.7.10")
19
- assert !ActiveRecord::Base.supports_import?(version)
20
- end
8
+ it "should support import" do
9
+ assert ActiveRecord::Base.supports_import?
21
10
  end
22
11
  end
23
12
 
@@ -49,18 +38,193 @@ def should_support_sqlite3_import_functionality
49
38
  assert_equal 2500, Topic.count, "Failed to insert all records. Make sure you have a supported version of SQLite3 (3.7.11 or higher) installed"
50
39
  end
51
40
  end
41
+ end
42
+ end
43
+
44
+ def should_support_sqlite_upsert_functionality
45
+ should_support_basic_on_duplicate_key_update
46
+ should_support_on_duplicate_key_ignore
47
+
48
+ describe "#import" do
49
+ extend ActiveSupport::TestCase::ImportAssertions
50
+
51
+ macro(:perform_import) { raise "supply your own #perform_import in a context below" }
52
+ macro(:updated_topic) { Topic.find(@topic.id) }
53
+
54
+ context "with :on_duplicate_key_ignore and validation checks turned off" do
55
+ let(:columns) { %w( id title author_name author_email_address parent_id ) }
56
+ let(:values) { [[99, "Book", "John Doe", "john@doe.com", 17]] }
57
+ let(:updated_values) { [[99, "Book - 2nd Edition", "Author Should Not Change", "johndoe@example.com", 57]] }
58
+
59
+ setup do
60
+ Topic.import columns, values, validate: false
61
+ end
62
+
63
+ it "should not update any records" do
64
+ result = Topic.import columns, updated_values, on_duplicate_key_ignore: true, validate: false
65
+ assert_equal [], result.ids
66
+ end
67
+ end
68
+
69
+ context "with :on_duplicate_key_update and validation checks turned off" do
70
+ asssertion_group(:should_support_on_duplicate_key_update) do
71
+ should_not_update_fields_not_mentioned
72
+ should_update_foreign_keys
73
+ should_not_update_created_at_on_timestamp_columns
74
+ should_update_updated_at_on_timestamp_columns
75
+ end
76
+
77
+ context "using a hash" do
78
+ context "with :columns a hash" do
79
+ let(:columns) { %w( id title author_name author_email_address parent_id ) }
80
+ let(:values) { [[99, "Book", "John Doe", "john@doe.com", 17]] }
81
+ let(:updated_values) { [[99, "Book - 2nd Edition", "Author Should Not Change", "johndoe@example.com", 57]] }
82
+
83
+ macro(:perform_import) do |*opts|
84
+ Topic.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: { conflict_target: :id, columns: update_columns }, validate: false)
85
+ end
86
+
87
+ setup do
88
+ Topic.import columns, values, validate: false
89
+ @topic = Topic.find 99
90
+ end
91
+
92
+ it "should not modify the passed in :on_duplicate_key_update columns array" do
93
+ assert_nothing_raised do
94
+ columns = %w(title author_name).freeze
95
+ Topic.import columns, [%w(foo, bar)], on_duplicate_key_update: { columns: columns }
96
+ end
97
+ end
98
+
99
+ context "using string hash map" do
100
+ let(:update_columns) { { "title" => "title", "author_email_address" => "author_email_address", "parent_id" => "parent_id" } }
101
+ should_support_on_duplicate_key_update
102
+ should_update_fields_mentioned
103
+ end
104
+
105
+ context "using string hash map, but specifying column mismatches" do
106
+ let(:update_columns) { { "title" => "author_email_address", "author_email_address" => "title", "parent_id" => "parent_id" } }
107
+ should_support_on_duplicate_key_update
108
+ should_update_fields_mentioned_with_hash_mappings
109
+ end
110
+
111
+ context "using symbol hash map" do
112
+ let(:update_columns) { { title: :title, author_email_address: :author_email_address, parent_id: :parent_id } }
113
+ should_support_on_duplicate_key_update
114
+ should_update_fields_mentioned
115
+ end
116
+
117
+ context "using symbol hash map, but specifying column mismatches" do
118
+ let(:update_columns) { { title: :author_email_address, author_email_address: :title, parent_id: :parent_id } }
119
+ should_support_on_duplicate_key_update
120
+ should_update_fields_mentioned_with_hash_mappings
121
+ end
122
+ end
123
+
124
+ context 'with :index_predicate' do
125
+ let(:columns) { %w( id device_id alarm_type status metadata ) }
126
+ let(:values) { [[99, 17, 1, 1, 'foo']] }
127
+ let(:updated_values) { [[99, 17, 1, 2, 'bar']] }
128
+
129
+ macro(:perform_import) do |*opts|
130
+ Alarm.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: { conflict_target: [:device_id, :alarm_type], index_predicate: 'status <> 0', columns: [:status] }, validate: false)
131
+ end
132
+
133
+ macro(:updated_alarm) { Alarm.find(@alarm.id) }
134
+
135
+ setup do
136
+ Alarm.import columns, values, validate: false
137
+ @alarm = Alarm.find 99
138
+ end
139
+
140
+ context 'supports on duplicate key update for partial indexes' do
141
+ it 'should not update created_at timestamp columns' do
142
+ Timecop.freeze Chronic.parse("5 minutes from now") do
143
+ perform_import
144
+ assert_in_delta @alarm.created_at.to_i, updated_alarm.created_at.to_i, 1
145
+ end
146
+ end
147
+
148
+ it 'should update updated_at timestamp columns' do
149
+ time = Chronic.parse("5 minutes from now")
150
+ Timecop.freeze time do
151
+ perform_import
152
+ assert_in_delta time.to_i, updated_alarm.updated_at.to_i, 1
153
+ end
154
+ end
155
+
156
+ it 'should not update fields not mentioned' do
157
+ perform_import
158
+ assert_equal 'foo', updated_alarm.metadata
159
+ end
160
+
161
+ it 'should update fields mentioned with hash mappings' do
162
+ perform_import
163
+ assert_equal 2, updated_alarm.status
164
+ end
165
+ end
166
+ end
167
+
168
+ context 'with :condition' do
169
+ let(:columns) { %w( id device_id alarm_type status metadata) }
170
+ let(:values) { [[99, 17, 1, 1, 'foo']] }
171
+ let(:updated_values) { [[99, 17, 1, 1, 'bar']] }
172
+
173
+ macro(:perform_import) do |*opts|
174
+ Alarm.import(
175
+ columns,
176
+ updated_values,
177
+ opts.extract_options!.merge(
178
+ on_duplicate_key_update: {
179
+ conflict_target: [:id],
180
+ condition: "alarms.metadata NOT LIKE '%foo%'",
181
+ columns: [:metadata]
182
+ },
183
+ validate: false
184
+ )
185
+ )
186
+ end
187
+
188
+ macro(:updated_alarm) { Alarm.find(@alarm.id) }
189
+
190
+ setup do
191
+ Alarm.import columns, values, validate: false
192
+ @alarm = Alarm.find 99
193
+ end
194
+
195
+ it 'should not update fields not matched' do
196
+ perform_import
197
+ assert_equal 'foo', updated_alarm.metadata
198
+ end
199
+ end
200
+
201
+ context "with no :conflict_target" do
202
+ context "with no primary key" do
203
+ it "raises ArgumentError" do
204
+ error = assert_raises ArgumentError do
205
+ Rule.import Build(3, :rules), on_duplicate_key_update: [:condition_text], validate: false
206
+ end
207
+ assert_match(/Expected :conflict_target to be specified/, error.message)
208
+ end
209
+ end
210
+ end
211
+
212
+ context "with no :columns" do
213
+ let(:columns) { %w( id title author_name author_email_address ) }
214
+ let(:values) { [[100, "Book", "John Doe", "john@doe.com"]] }
215
+ let(:updated_values) { [[100, "Title Should Not Change", "Author Should Not Change", "john@nogo.com"]] }
52
216
 
53
- context "with :on_duplicate_key_update" do
54
- let(:topics) { Build(1, :topics) }
217
+ macro(:perform_import) do |*opts|
218
+ Topic.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: { conflict_target: :id }, validate: false)
219
+ end
55
220
 
56
- it "should log a warning message" do
57
- log = StringIO.new
58
- logger = Logger.new(log)
59
- logger.level = Logger::WARN
60
- ActiveRecord::Base.connection.stubs(:logger).returns(logger)
221
+ setup do
222
+ Topic.import columns, values, validate: false
223
+ @topic = Topic.find 100
224
+ end
61
225
 
62
- Topic.import topics, on_duplicate_key_update: true
63
- assert_match(/Ignoring on_duplicate_key_update/, log.string)
226
+ should_update_updated_at_on_timestamp_columns
227
+ end
64
228
  end
65
229
  end
66
230
  end
@@ -30,4 +30,12 @@ describe ".synchronize" do
30
30
  assert_equal false, topics[1].changed?, "the second record was dirty"
31
31
  assert_equal false, topics[2].changed?, "the third record was dirty"
32
32
  end
33
+
34
+ it "ignores default scope" do
35
+ # update records outside of ActiveRecord knowing about it
36
+ Topic.connection.execute( "UPDATE #{Topic.table_name} SET approved='0' WHERE id=#{topics[0].id}", "Updating record 1 without ActiveRecord" )
37
+
38
+ Topic.synchronize topics
39
+ assert_equal false, topics[0].approved
40
+ end
33
41
  end
data/test/test_helper.rb CHANGED
@@ -26,7 +26,20 @@ end
26
26
 
27
27
  require 'timecop'
28
28
  require 'chronic'
29
- require 'composite_primary_keys'
29
+
30
+ begin
31
+ require 'composite_primary_keys'
32
+ rescue LoadError
33
+ ENV["SKIP_COMPOSITE_PK"] = "true"
34
+ end
35
+
36
+ # Support MySQL 5.7
37
+ if ActiveSupport::VERSION::STRING < "4.1"
38
+ require "active_record/connection_adapters/mysql2_adapter"
39
+ class ActiveRecord::ConnectionAdapters::Mysql2Adapter
40
+ NATIVE_DATABASE_TYPES[:primary_key] = "int(11) auto_increment PRIMARY KEY"
41
+ end
42
+ end
30
43
 
31
44
  require "ruby-debug" if RUBY_VERSION.to_f < 1.9
32
45
 
@@ -35,7 +48,15 @@ adapter = ENV["ARE_DB"] || "sqlite3"
35
48
  FileUtils.mkdir_p 'log'
36
49
  ActiveRecord::Base.logger = Logger.new("log/test.log")
37
50
  ActiveRecord::Base.logger.level = Logger::DEBUG
38
- ActiveRecord::Base.configurations["test"] = YAML.load_file(test_dir.join("database.yml"))[adapter]
51
+
52
+ if ENV['AR_VERSION'].to_f >= 6.0
53
+ yaml_config = YAML.load_file(test_dir.join("database.yml"))[adapter]
54
+ config = ActiveRecord::DatabaseConfigurations::HashConfig.new("test", adapter, yaml_config)
55
+ ActiveRecord::Base.configurations.configurations << config
56
+ else
57
+ ActiveRecord::Base.configurations["test"] = YAML.load_file(test_dir.join("database.yml"))[adapter]
58
+ end
59
+
39
60
  ActiveRecord::Base.default_timezone = :utc
40
61
 
41
62
  require "activerecord-import"
@@ -45,7 +66,7 @@ ActiveSupport::Notifications.subscribe(/active_record.sql/) do |_, _, _, _, hsh|
45
66
  ActiveRecord::Base.logger.info hsh[:sql]
46
67
  end
47
68
 
48
- require "factory_girl"
69
+ require "factory_bot"
49
70
  Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each { |file| require file }
50
71
 
51
72
  # Load base/generic schema
@@ -8,6 +8,15 @@ describe ActiveRecord::Import::ValueSetsBytesParser do
8
8
  let(:base_sql) { "INSERT INTO atable (a,b,c)" }
9
9
  let(:values) { ["(1,2,3)", "(2,3,4)", "(3,4,5)"] }
10
10
 
11
+ context "when the max allowed bytes is 30 and the base SQL is 26 bytes" do
12
+ it "should raise ActiveRecord::Import::ValueSetTooLargeError" do
13
+ error = assert_raises ActiveRecord::Import::ValueSetTooLargeError do
14
+ parser.parse values, reserved_bytes: base_sql.size, max_bytes: 30
15
+ end
16
+ assert_match(/33 bytes exceeds the max allowed for an insert \[30\]/, error.message)
17
+ end
18
+ end
19
+
11
20
  context "when the max allowed bytes is 33 and the base SQL is 26 bytes" do
12
21
  it "should return 3 value sets when given 3 value sets of 7 bytes a piece" do
13
22
  value_sets = parser.parse values, reserved_bytes: base_sql.size, max_bytes: 33
@@ -54,7 +63,8 @@ describe ActiveRecord::Import::ValueSetsBytesParser do
54
63
  values = [
55
64
  "('1','2','3')",
56
65
  "('4','5','6')",
57
- "('7','8','9')"]
66
+ "('7','8','9')"
67
+ ]
58
68
 
59
69
  base_sql_size_in_bytes = 15
60
70
  max_bytes = 30
@@ -79,7 +89,8 @@ describe ActiveRecord::Import::ValueSetsBytesParser do
79
89
  # each accented e should be 2 bytes, so each entry is 6 bytes instead of 5
80
90
  values = [
81
91
  "('é')",
82
- "('é')"]
92
+ "('é')"
93
+ ]
83
94
 
84
95
  base_sql_size_in_bytes = 15
85
96
  max_bytes = 26
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-import
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.2
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zach Dennis
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2017-03-14 00:00:00.000000000 Z
11
+ date: 2021-05-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -67,12 +67,16 @@ files:
67
67
  - benchmarks/models/test_innodb.rb
68
68
  - benchmarks/models/test_memory.rb
69
69
  - benchmarks/models/test_myisam.rb
70
- - benchmarks/schema/mysql_schema.rb
70
+ - benchmarks/schema/mysql2_schema.rb
71
71
  - gemfiles/3.2.gemfile
72
72
  - gemfiles/4.0.gemfile
73
73
  - gemfiles/4.1.gemfile
74
74
  - gemfiles/4.2.gemfile
75
75
  - gemfiles/5.0.gemfile
76
+ - gemfiles/5.1.gemfile
77
+ - gemfiles/5.2.gemfile
78
+ - gemfiles/6.0.gemfile
79
+ - gemfiles/6.1.gemfile
76
80
  - lib/activerecord-import.rb
77
81
  - lib/activerecord-import/active_record/adapters/abstract_adapter.rb
78
82
  - lib/activerecord-import/active_record/adapters/jdbcmysql_adapter.rb
@@ -99,6 +103,7 @@ files:
99
103
  - test/adapters/jdbcmysql.rb
100
104
  - test/adapters/jdbcpostgresql.rb
101
105
  - test/adapters/jdbcsqlite3.rb
106
+ - test/adapters/makara_postgis.rb
102
107
  - test/adapters/mysql2.rb
103
108
  - test/adapters/mysql2_makara.rb
104
109
  - test/adapters/mysql2spatial.rb
@@ -113,7 +118,11 @@ files:
113
118
  - test/jdbcmysql/import_test.rb
114
119
  - test/jdbcpostgresql/import_test.rb
115
120
  - test/jdbcsqlite3/import_test.rb
121
+ - test/makara_postgis/import_test.rb
122
+ - test/models/account.rb
116
123
  - test/models/alarm.rb
124
+ - test/models/animal.rb
125
+ - test/models/bike_maker.rb
117
126
  - test/models/book.rb
118
127
  - test/models/car.rb
119
128
  - test/models/chapter.rb
@@ -126,6 +135,8 @@ files:
126
135
  - test/models/rule.rb
127
136
  - test/models/tag.rb
128
137
  - test/models/topic.rb
138
+ - test/models/user.rb
139
+ - test/models/user_token.rb
129
140
  - test/models/vendor.rb
130
141
  - test/models/widget.rb
131
142
  - test/mysql2/import_test.rb
@@ -135,9 +146,10 @@ files:
135
146
  - test/postgresql/import_test.rb
136
147
  - test/schema/generic_schema.rb
137
148
  - test/schema/jdbcpostgresql_schema.rb
138
- - test/schema/mysql_schema.rb
149
+ - test/schema/mysql2_schema.rb
139
150
  - test/schema/postgis_schema.rb
140
151
  - test/schema/postgresql_schema.rb
152
+ - test/schema/sqlite3_schema.rb
141
153
  - test/schema/version.rb
142
154
  - test/sqlite3/import_test.rb
143
155
  - test/support/active_support/test_case_extensions.rb
@@ -155,11 +167,11 @@ files:
155
167
  - test/travis/database.yml
156
168
  - test/value_sets_bytes_parser_test.rb
157
169
  - test/value_sets_records_parser_test.rb
158
- homepage: http://github.com/zdennis/activerecord-import
170
+ homepage: https://github.com/zdennis/activerecord-import
159
171
  licenses:
160
- - Ruby
172
+ - MIT
161
173
  metadata: {}
162
- post_install_message:
174
+ post_install_message:
163
175
  rdoc_options: []
164
176
  require_paths:
165
177
  - lib
@@ -167,22 +179,22 @@ required_ruby_version: !ruby/object:Gem::Requirement
167
179
  requirements:
168
180
  - - ">="
169
181
  - !ruby/object:Gem::Version
170
- version: 1.9.2
182
+ version: 2.0.0
171
183
  required_rubygems_version: !ruby/object:Gem::Requirement
172
184
  requirements:
173
185
  - - ">="
174
186
  - !ruby/object:Gem::Version
175
187
  version: '0'
176
188
  requirements: []
177
- rubyforge_project:
178
- rubygems_version: 2.6.2
179
- signing_key:
189
+ rubygems_version: 3.0.8
190
+ signing_key:
180
191
  specification_version: 4
181
192
  summary: Bulk insert extension for ActiveRecord
182
193
  test_files:
183
194
  - test/adapters/jdbcmysql.rb
184
195
  - test/adapters/jdbcpostgresql.rb
185
196
  - test/adapters/jdbcsqlite3.rb
197
+ - test/adapters/makara_postgis.rb
186
198
  - test/adapters/mysql2.rb
187
199
  - test/adapters/mysql2_makara.rb
188
200
  - test/adapters/mysql2spatial.rb
@@ -197,7 +209,11 @@ test_files:
197
209
  - test/jdbcmysql/import_test.rb
198
210
  - test/jdbcpostgresql/import_test.rb
199
211
  - test/jdbcsqlite3/import_test.rb
212
+ - test/makara_postgis/import_test.rb
213
+ - test/models/account.rb
200
214
  - test/models/alarm.rb
215
+ - test/models/animal.rb
216
+ - test/models/bike_maker.rb
201
217
  - test/models/book.rb
202
218
  - test/models/car.rb
203
219
  - test/models/chapter.rb
@@ -210,6 +226,8 @@ test_files:
210
226
  - test/models/rule.rb
211
227
  - test/models/tag.rb
212
228
  - test/models/topic.rb
229
+ - test/models/user.rb
230
+ - test/models/user_token.rb
213
231
  - test/models/vendor.rb
214
232
  - test/models/widget.rb
215
233
  - test/mysql2/import_test.rb
@@ -219,9 +237,10 @@ test_files:
219
237
  - test/postgresql/import_test.rb
220
238
  - test/schema/generic_schema.rb
221
239
  - test/schema/jdbcpostgresql_schema.rb
222
- - test/schema/mysql_schema.rb
240
+ - test/schema/mysql2_schema.rb
223
241
  - test/schema/postgis_schema.rb
224
242
  - test/schema/postgresql_schema.rb
243
+ - test/schema/sqlite3_schema.rb
225
244
  - test/schema/version.rb
226
245
  - test/sqlite3/import_test.rb
227
246
  - test/support/active_support/test_case_extensions.rb