persistent_enum 1.2.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3cde6573673d70f73eb38d09bd9e4f0fc445e7953f9cf63cb3f631afc2c2b3da
4
+ data.tar.gz: 2713d64c90a9e857120099c07a7cad9d51dc477f9f1c859086d1cd3d1ac2a0cb
5
+ SHA512:
6
+ metadata.gz: 6c45dd0b3fb7f72e9709f8972f0a7d03b247f542f169c6534680034dee043d1287f2c29a036da1a386bf9f5cce3418f2ff30a17f6da4d4454dc290602c5a5bd7
7
+ data.tar.gz: 0037fd38a29ae4da1368b0cb13d6da4ae826163dffc4b9c9c02870ee7bf66f897a533f586e73a34037e94603eba1bc015d87d2d5f95cafd062e16b26804c9c5a
@@ -0,0 +1,162 @@
1
+ version: 2.1
2
+
3
+ executors:
4
+ ruby-sqlite: &ruby
5
+ parameters:
6
+ ruby-version:
7
+ type: string
8
+ default: "2.6"
9
+ gemfile:
10
+ type: string
11
+ default: "Gemfile"
12
+ environment:
13
+ TEST_DATABASE_ENVIRONMENT: sqlite3
14
+ docker:
15
+ - &ruby_docker_ruby
16
+ image: circleci/ruby:<< parameters.ruby-version >>
17
+ environment:
18
+ BUNDLE_JOBS: 3
19
+ BUNDLE_RETRY: 3
20
+ BUNDLE_PATH: vendor/bundle
21
+ RAILS_ENV: test
22
+ BUNDLE_GEMFILE: << parameters.gemfile >>
23
+ ruby-pg:
24
+ <<: *ruby
25
+ environment:
26
+ TEST_DATABASE_ENVIRONMENT: postgresql
27
+ PGHOST: 127.0.0.1
28
+ PGUSER: eikaiwa
29
+ docker:
30
+ - *ruby_docker_ruby
31
+ - image: circleci/postgres:11-alpine
32
+ environment:
33
+ POSTGRES_USER: eikaiwa
34
+ POSTGRES_DB: persistent_enum_test
35
+ POSTGRES_PASSWORD: ""
36
+ ruby-mysql:
37
+ <<: *ruby
38
+ environment:
39
+ TEST_DATABASE_ENVIRONMENT: mysql2
40
+ MYSQL_HOST: 127.0.0.1
41
+ MYSQL_USER: root
42
+ docker:
43
+ - *ruby_docker_ruby
44
+ - image: tkuchiki/delayed-mysql
45
+ environment:
46
+ MYSQL_ALLOW_EMPTY_PASSWORD: yes
47
+ MYSQL_ROOT_PASSWORD: ''
48
+ MYSQL_DATABASE: persistent_enum_test
49
+
50
+ jobs:
51
+ test:
52
+ parameters:
53
+ ex:
54
+ type: executor
55
+ database-steps:
56
+ type: steps
57
+ default: []
58
+ executor: << parameters.ex >>
59
+ parallelism: 1
60
+ steps:
61
+ - checkout
62
+
63
+ - run:
64
+ # Remove the non-appraisal gemfile for safety: we never want to use it.
65
+ name: Prepare bundler
66
+ command: bundle -v && rm Gemfile
67
+
68
+ - run:
69
+ name: Compute a gemfile lock
70
+ command: bundle lock && cp "${BUNDLE_GEMFILE}.lock" /tmp/gem-lock
71
+
72
+ - restore_cache:
73
+ keys:
74
+ - iknow_viewmodels-{{ checksum "/tmp/gem-lock" }}
75
+ - iknow_viewmodels-
76
+
77
+ - run:
78
+ name: Bundle Install
79
+ command: bundle check || bundle install
80
+
81
+ - save_cache:
82
+ key: iknow_viewmodels-{{ checksum "/tmp/gem-lock" }}
83
+ paths:
84
+ - vendor/bundle
85
+
86
+ - steps: << parameters.database-steps >>
87
+
88
+ - run:
89
+ name: Run rspec
90
+ command: bundle exec rspec --profile 10 --format RspecJunitFormatter --out test_results/rspec.xml --format progress
91
+
92
+ - store_test_results:
93
+ path: test_results
94
+
95
+ publish:
96
+ executor: ruby-sqlite
97
+ steps:
98
+ - checkout
99
+ - run:
100
+ name: Setup Rubygems
101
+ command: |
102
+ mkdir ~/.gem &&
103
+ echo -e "---\r\n:rubygems_api_key: $RUBYGEMS_API_KEY" > ~/.gem/credentials &&
104
+ chmod 0600 ~/.gem/credentials
105
+ - run:
106
+ name: Publish to Rubygems
107
+ command: |
108
+ gem build persistent_enum.gemspec
109
+ gem push persistent_enum-*.gem
110
+
111
+ workflows:
112
+ version: 2
113
+ build:
114
+ jobs:
115
+ - test:
116
+ name: 'ruby 2.6 rails 5.2 sqlite'
117
+ ex:
118
+ name: ruby-sqlite
119
+ ruby-version: "2.6"
120
+ gemfile: gemfiles/rails_5_2.gemfile
121
+ - test:
122
+ name: 'ruby 2.6 rails 6.0 sqlite'
123
+ ex:
124
+ name: ruby-sqlite
125
+ ruby-version: "2.6"
126
+ gemfile: gemfiles/rails_6_0_beta.gemfile
127
+ - test:
128
+ name: 'ruby 2.6 rails 5.2 pg'
129
+ ex:
130
+ name: ruby-pg
131
+ ruby-version: "2.6"
132
+ gemfile: gemfiles/rails_5_2.gemfile
133
+ database-steps: &pg_wait
134
+ - run: dockerize -wait tcp://localhost:5432 -timeout 1m
135
+ - test:
136
+ name: 'ruby 2.6 rails 6.0 pg'
137
+ ex:
138
+ name: ruby-pg
139
+ ruby-version: "2.6"
140
+ gemfile: gemfiles/rails_6_0_beta.gemfile
141
+ database-steps: *pg_wait
142
+ - test:
143
+ name: 'ruby 2.6 rails 5.2 mysql'
144
+ ex:
145
+ name: ruby-mysql
146
+ ruby-version: "2.6"
147
+ gemfile: gemfiles/rails_5_2.gemfile
148
+ database-steps: &mysql_wait
149
+ - run: dockerize -wait tcp://localhost:3306 -timeout 1m
150
+ - test:
151
+ name: 'ruby 2.6 rails 6.0 mysql'
152
+ ex:
153
+ name: ruby-mysql
154
+ ruby-version: "2.6"
155
+ gemfile: gemfiles/rails_6_0_beta.gemfile
156
+ database-steps: *mysql_wait
157
+ - publish:
158
+ filters:
159
+ branches:
160
+ only: master
161
+ tags:
162
+ ignore: /.*/
data/.gitignore ADDED
@@ -0,0 +1,25 @@
1
+ *~
2
+ .#*
3
+ *.gem
4
+ *.rbc
5
+ .bundle
6
+ .config
7
+ .yardoc
8
+ InstalledFiles
9
+ _yardoc
10
+ coverage
11
+ doc/
12
+ lib/bundler/man
13
+ pkg
14
+ rdoc
15
+ spec/reports
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
19
+ *.bundle
20
+ *.so
21
+ *.o
22
+ *.a
23
+ mkmf.log
24
+ gemfiles/*.gemfile.lock
25
+ Gemfile.lock
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/.travis.yml ADDED
@@ -0,0 +1,39 @@
1
+ dist: trusty
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+
6
+ rvm:
7
+ - 2.5
8
+
9
+ gemfile:
10
+ - gemfiles/rails_5_2.gemfile
11
+
12
+ services:
13
+ - mysql
14
+
15
+ addons:
16
+ postgresql: "10"
17
+ apt:
18
+ packages:
19
+ - postgresql-10
20
+ - postgresql-client-10
21
+ - postgresql-server-dev-10
22
+ env:
23
+ global:
24
+ - PGPORT=5433
25
+ matrix:
26
+ - TEST_DATABASE_ENVIRONMENT=postgresql
27
+ - TEST_DATABASE_ENVIRONMENT=mysql2
28
+ - TEST_DATABASE_ENVIRONMENT=sqlite3
29
+
30
+ before_install:
31
+ # Travis' Ruby 2.5.0 ships broken rubygems, won't run rake.
32
+ # Workaround: update rubygems. See travis-ci issue 8978
33
+ - gem install bundler
34
+ before_script:
35
+ - psql -c 'CREATE DATABASE persistent_enum_test;'
36
+ - mysql -e 'CREATE DATABASE persistent_enum_test;'
37
+
38
+ notifications:
39
+ email: false
data/Appraisals ADDED
@@ -0,0 +1,7 @@
1
+ appraise "rails-5-2" do
2
+ gem "rails", "~> 5.2.0"
3
+ end
4
+
5
+ appraise "rails-6-0-beta" do
6
+ gem "rails", "~> 6.0.0.beta"
7
+ end
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in acts_as_enum.gemspec
4
+ gemspec
5
+
6
+ gem 'rspec_junit_formatter'
data/LICENSE.txt ADDED
@@ -0,0 +1,9 @@
1
+ Copyright (c) 2014, Cerego LLC. All rights reserved.
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+
7
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8
+
9
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # PersistentEnum
2
+ [![Build Status](https://travis-ci.org/iknow/persistent_enum.svg?branch=master)](https://travis-ci.org/iknow/persistent_enum)
3
+
4
+ Provide an ActiveRecord model that behaves as a database-backed enumeration
5
+ between indices and symbolic values. This allows us to have a valid foreign key
6
+ which behaves like a enumeration. Values are cached at startup, and cannot be
7
+ changed.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+ begin
3
+ require 'rspec/core/rake_task'
4
+ RSpec::Core::RakeTask.new(:spec)
5
+ task :default => :spec
6
+ rescue LoadError
7
+ puts "rspec unavailable"
8
+ end
@@ -0,0 +1,7 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 5.0.0"
6
+
7
+ gemspec :path => "../"
@@ -0,0 +1,7 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 5.1.0"
6
+
7
+ gemspec path: "../"
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rspec_junit_formatter"
6
+ gem "rails", "~> 5.2.0"
7
+
8
+ gemspec path: "../"
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rspec_junit_formatter"
6
+ gem "rails", "~> 6.0.0.beta"
7
+
8
+ gemspec path: "../"
@@ -0,0 +1,437 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'persistent_enum/version'
4
+ require 'persistent_enum/acts_as_enum'
5
+
6
+ require 'active_support'
7
+ require 'active_support/inflector'
8
+ require 'active_record'
9
+ require 'activerecord-import'
10
+
11
+ # Provide a database-backed enumeration between indices and symbolic
12
+ # values. This allows us to have a valid foreign key which behaves like a
13
+ # enumeration. Values are cached at startup, and cannot be changed.
14
+ module PersistentEnum
15
+ extend ActiveSupport::Concern
16
+ class EnumTableInvalid < RuntimeError; end
17
+ class MissingEnumTypeError < RuntimeError; end
18
+
19
+ module ClassMethods
20
+ def acts_as_enum(required_constants, name_attr: :name, sql_enum_type: nil, &constant_init_block)
21
+ include ActsAsEnum
22
+ enum_spec = EnumSpec.new(constant_init_block, required_constants, name_attr, sql_enum_type)
23
+ initialize_acts_as_enum(enum_spec)
24
+ end
25
+
26
+ # Sets up a association with an enumeration record type. Key resolution is
27
+ # done via the enumeration type's cache rather than ActiveRecord. The
28
+ # setter accepts either a model type or the enum constant name as a symbol
29
+ # or string.
30
+ def belongs_to_enum(enum_name, class_name: enum_name.to_s.camelize, foreign_key: "#{enum_name}_id")
31
+ target_class = class_name.constantize
32
+
33
+ define_method(enum_name) do
34
+ target_id = read_attribute(foreign_key)
35
+ target_class[target_id]
36
+ end
37
+
38
+ define_method("#{enum_name}=") do |enum_member|
39
+ id = case enum_member
40
+ when nil
41
+ nil
42
+ when target_class
43
+ enum_member.ordinal
44
+ else
45
+ m = target_class.value_of(enum_member)
46
+ raise NameError.new("#{target_class}: Invalid enum constant '#{enum_member}'") if m.nil?
47
+
48
+ m.ordinal
49
+ end
50
+ write_attribute(foreign_key, id)
51
+ end
52
+
53
+ # All enum members must be valid
54
+ validates foreign_key, inclusion: { in: ->(_r) { target_class.all_ordinals } }, allow_nil: true
55
+
56
+ # New enum members must be currently active
57
+ validates foreign_key, inclusion: { in: ->(_r) { target_class.ordinals } }, allow_nil: true, on: :create
58
+ end
59
+ end
60
+
61
+ # Closes over the arguments to `acts_as_enum` so that it can be reloaded.
62
+ EnumSpec = Struct.new(:constant_block, :constant_hash, :name_attr, :sql_enum_type) do
63
+ def initialize(constant_block, constant_hash, name_attr, sql_enum_type)
64
+ unless constant_block.nil? ^ constant_hash.nil?
65
+ raise ArgumentError.new('Constants must be provided by exactly one of hash argument or builder block')
66
+ end
67
+
68
+ super(constant_block, constant_hash, name_attr.to_s, sql_enum_type)
69
+ freeze
70
+ end
71
+
72
+ def required_members
73
+ constant_hash || ConstantEvaluator.new.evaluate(&constant_block)
74
+ end
75
+
76
+ class ConstantEvaluator
77
+ def evaluate(&block)
78
+ @constants = {}
79
+ self.instance_eval(&block)
80
+ @constants
81
+ end
82
+
83
+ def constant!(name, **args)
84
+ @constants[name] = args
85
+ end
86
+
87
+ def method_missing(name, **args)
88
+ constant!(name, **args)
89
+ end
90
+
91
+ def respond_to_missing?(_name, _include_all = true)
92
+ true
93
+ end
94
+ end
95
+ end
96
+
97
+ class << self
98
+ # Given an 'enum-like' table with (id, name, ...) structure and a set of
99
+ # enum members specified as either [name, ...] or {name: {attr: val, ...}, ...},
100
+ # ensure that there is a row in the table corresponding to each name, and
101
+ # cache the models as constants on the model class.
102
+ #
103
+ # When using a database such as postgresql that supports native enumerated
104
+ # types, can additionally specify a native enum type to use as the primary
105
+ # key. In this case, the required members will be added to the native type
106
+ # by name using `ALTER TYPE` before insertion. This ensures that enum table
107
+ # ids will have predictable values and can therefore be used in database
108
+ # level constraints.
109
+ def cache_constants(model, required_members, name_attr: 'name', required_attributes: nil, sql_enum_type: nil)
110
+ # normalize member specification
111
+ unless required_members.is_a?(Hash)
112
+ required_members = required_members.each_with_object({}) { |c, h| h[c] = {} }
113
+ end
114
+
115
+ # Normalize symbols
116
+ name_attr = name_attr.to_s
117
+ required_members = required_members.each_with_object({}) do |(name, attrs), h|
118
+ h[name.to_s] = attrs.transform_keys(&:to_s)
119
+ end
120
+ required_attributes = required_attributes.map(&:to_s) if required_attributes
121
+
122
+ # We need to cope with (a) loading this class and (b) ensuring that all the
123
+ # constants are defined (if not functional) in the case that the database
124
+ # isn't present yet. If no database is present, create dummy values to
125
+ # populate the constants.
126
+ values =
127
+ begin
128
+ cache_constants_in_table(model, name_attr, required_members, required_attributes, sql_enum_type)
129
+ rescue EnumTableInvalid => ex
130
+ # If we're running the application in any way, under no circumstances
131
+ # do we want to introduce the dummy models: crash out now. Our
132
+ # conservative heuristic to detect a 'safe' loading outside the
133
+ # application is whether there is a current Rake task.
134
+ unless Object.const_defined?(:Rake) && Rake.try(:application)&.top_level_tasks.present?
135
+ raise
136
+ end
137
+
138
+ # Otherwise, we want to try as hard as possible to allow the
139
+ # application to be initialized enough to run the Rake task (e.g.
140
+ # db:migrate).
141
+ log_warning("Database table initialization error for model #{model.name}, "\
142
+ 'initializing constants with dummy records instead: ' +
143
+ ex.message)
144
+ cache_constants_in_dummy_class(model, name_attr, required_members, required_attributes, sql_enum_type)
145
+ end
146
+
147
+ return cache_values(model, values, name_attr)
148
+ end
149
+
150
+ # Given an 'enum-like' table with (id, name, ...) structure, load existing
151
+ # records from the database and cache them in constants on this class
152
+ def cache_records(model, name_attr: :name)
153
+ if model.table_exists?
154
+ values = model.scoped
155
+ cache_values(model, values, name_attr)
156
+ else
157
+ puts "Database table for model #{model.name} doesn't exist, no constants cached."
158
+ end
159
+ rescue ActiveRecord::NoDatabaseError
160
+ puts "Database for model #{model.name} doesn't exist, no constants cached."
161
+ end
162
+
163
+ def dummy_class(model, name_attr)
164
+ if model.const_defined?(:DummyModel, false)
165
+ dummy_class = model::DummyModel
166
+ unless dummy_class.superclass == AbstractDummyModel && dummy_class.name_attr == name_attr
167
+ raise NameError.new("PersistentEnum dummy class type mismatch: '#{dummy_class.inspect}' does not match '#{model.name}'")
168
+ end
169
+
170
+ dummy_class
171
+ else
172
+ nil
173
+ end
174
+ end
175
+
176
+ private
177
+
178
+ def cache_constants_in_table(model, name_attr, required_members, required_attributes, sql_enum_type)
179
+ begin
180
+ unless model.table_exists?
181
+ raise EnumTableInvalid.new("Database table for model #{model.name} doesn't exist")
182
+ end
183
+ rescue ActiveRecord::NoDatabaseError
184
+ raise EnumTableInvalid.new("Database for model #{model.name} doesn't exist")
185
+ end
186
+
187
+ internal_attributes = ['id', name_attr]
188
+ table_attributes = (model.column_names - internal_attributes)
189
+
190
+ # If not otherwise specified, non-null attributes without defaults are required
191
+ optional_attributes = model.columns.select { |col| col.null || !col.default.nil? }.map(&:name) - internal_attributes
192
+ required_attributes ||= table_attributes - optional_attributes
193
+
194
+ column_defaults = model.column_defaults
195
+
196
+ unless (unknown_attributes = (required_attributes - table_attributes)).blank?
197
+ log_warning("PersistentEnum error: required attributes #{unknown_attrs.inspect} for model #{model.name} not found in table - ignoring.")
198
+ required_attributes -= unknown_attributes
199
+ end
200
+
201
+ unless model.connection.indexes(model.table_name).detect { |i| i.columns == [name_attr] && i.unique }
202
+ raise EnumTableInvalid.new("detected missing unique index on '#{name_attr}'")
203
+ end
204
+
205
+ if model.connection.open_transactions > 0
206
+ # This case particularly doesn't fall back to dummy initialization as it
207
+ # indicates that the PersistentEnum wasn't initialized at startup: a
208
+ # silent fallback to a dummy model could go unnoticed.
209
+ raise RuntimeError.new("PersistentEnum model #{model.name} detected unsafe class initialization during a transaction: aborting.")
210
+ end
211
+
212
+ if sql_enum_type
213
+ begin
214
+ ensure_sql_enum_members(model.connection, required_members.keys, sql_enum_type)
215
+ rescue MissingEnumTypeError
216
+ log_warning("Database enum type missing for PersistentEnum #{model.name}: falling back to default id handling")
217
+ sql_enum_type = nil
218
+ end
219
+ end
220
+
221
+ expected_rows = required_members.map do |name, attrs|
222
+ attr_names = attrs.keys
223
+
224
+ if (extra_attrs = (attr_names - table_attributes)).present?
225
+ log_warning("PersistentEnum error: specified attributes #{extra_attrs.inspect} for model #{model.name} missing from table - ignoring.")
226
+ attrs = attrs.except(*extra_attrs)
227
+ end
228
+
229
+ if (missing_attrs = (required_attributes - attr_names)).present?
230
+ raise EnumTableInvalid.new("enum member error: required attributes #{missing_attrs.inspect} not provided")
231
+ end
232
+
233
+ new_attrs = attrs.dup
234
+ new_attrs[name_attr] = name
235
+ new_attrs['id'] = name if sql_enum_type
236
+ (optional_attributes - attr_names).each do |default_attr|
237
+ new_attrs[default_attr] = column_defaults[default_attr]
238
+ end
239
+ new_attrs
240
+ end
241
+
242
+ model.transaction do
243
+ upsert_records(model, name_attr, expected_rows)
244
+ model.where(name_attr => required_members.keys).to_a
245
+ end
246
+ end
247
+
248
+ def cache_constants_in_dummy_class(model, name_attr, required_members, _required_attributes, sql_enum_type)
249
+ dummyclass = build_dummy_class(model, name_attr)
250
+
251
+ next_id = 999999999
252
+
253
+ required_members.map do |member_name, attrs|
254
+ id = sql_enum_type ? member_name : (next_id += 1)
255
+ dummyclass.new(id, member_name, attrs)
256
+ end
257
+ end
258
+
259
+ def upsert_records(model, name_attr, expected_rows)
260
+ # Not all rows will have the same attributes to upsert: group and upsert by attributes
261
+ expected_rows.group_by(&:keys).each do |row_attrs, rows|
262
+ upsert_columns = row_attrs - [name_attr, 'id']
263
+
264
+ case model.connection.adapter_name
265
+ when 'PostgreSQL'
266
+ model.import!(rows, on_duplicate_key_update: { conflict_target: [name_attr], columns: upsert_columns })
267
+ when 'Mysql2'
268
+ if upsert_columns.present?
269
+ # Even for identical rows in the same order, a INSERT .. ON DUPLICATE
270
+ # KEY UPDATE can deadlock with itself. Obtain write locks in advance.
271
+ model.lock('FOR UPDATE').order(:id).pluck(:id)
272
+ model.import!(rows, on_duplicate_key_update: upsert_columns)
273
+ else
274
+ model.import!(rows, on_duplicate_key_ignore: true)
275
+ end
276
+ else
277
+ # No upsert support: use first_or_create optimistically
278
+ rows.each do |row|
279
+ record = model.lock.where(name_attr => row[name_attr]).first_or_create!(row.except('id', name_attr))
280
+ record.assign_attributes(row)
281
+ record.validate!
282
+ record.save! if record.changed?
283
+ end
284
+ end
285
+ end
286
+ end
287
+
288
+ ENUM_TYPE_LOCK_KEY = 0x757a6cafedc6084d # Random 64-bit key
289
+
290
+ def ensure_sql_enum_members(connection, names, sql_enum_type)
291
+ # It may be the case that an enum type doesn't yet exist despite the table
292
+ # existing, for example if the table is presently being migrated to an
293
+ # enum type. If this is the case, warn and fall back to standard id handling.
294
+ type_exists = ActiveRecord::Base.connection.select_value(
295
+ "SELECT true FROM pg_type WHERE typname = #{connection.quote(sql_enum_type)}")
296
+ raise MissingEnumTypeError.new unless type_exists
297
+
298
+ # ALTER TYPE may not be performed within a transaction: obtain a
299
+ # session-level advisory lock to prevent racing.
300
+ begin
301
+ connection.execute("SELECT pg_advisory_lock(#{ENUM_TYPE_LOCK_KEY})")
302
+
303
+ quoted_type = connection.quote_table_name(sql_enum_type)
304
+ current_members = connection.select_values(<<~SQL)
305
+ SELECT unnest(enum_range(null::#{quoted_type}, null::#{quoted_type}));
306
+ SQL
307
+
308
+ (names - current_members).each do |name|
309
+ connection.execute("ALTER TYPE #{quoted_type} ADD VALUE #{connection.quote(name)}")
310
+ end
311
+ ensure
312
+ connection.execute("SELECT pg_advisory_unlock(#{ENUM_TYPE_LOCK_KEY})")
313
+ end
314
+ end
315
+
316
+ # Set each value as a constant on this class. If reinitializing, only
317
+ # replace if the enum constant has changed, otherwise update attributes as
318
+ # necessary.
319
+ def cache_values(model, values, name_attr)
320
+ values.map do |value|
321
+ constant_name = constant_name(value.read_attribute(name_attr))
322
+
323
+ if model.const_defined?(constant_name, false)
324
+ existing_value = model.const_get(constant_name, false)
325
+ if existing_value.is_a?(AbstractDummyModel) ||
326
+ existing_value.read_attribute(name_attr) != value.read_attribute(name_attr)
327
+ then
328
+ # Replace with new value
329
+ model.send(:remove_const, constant_name)
330
+ model.const_set(constant_name, value)
331
+ elsif existing_value.attributes != value.attributes
332
+ existing_value.reload.freeze
333
+ else
334
+ existing_value
335
+ end
336
+ else
337
+ model.const_set(constant_name, value)
338
+ end
339
+ end
340
+ end
341
+
342
+ def constant_name(member_name)
343
+ value = member_name.strip.gsub(/[^\w\s-]/, '_').underscore
344
+ return nil if value.blank?
345
+
346
+ value.gsub!(/\s+/, '_')
347
+ value.gsub!(/_{2,}/, '_')
348
+ value.upcase!
349
+ value
350
+ end
351
+
352
+ def log_warning(message)
353
+ if (logger = ActiveRecord::Base.logger)
354
+ logger.warn(message)
355
+ else
356
+ STDERR.puts(message)
357
+ end
358
+ end
359
+
360
+ class AbstractDummyModel
361
+ attr_reader :ordinal, :enum_constant, :attributes
362
+
363
+ def initialize(id, name, attributes = {})
364
+ @ordinal = id
365
+ @enum_constant = name
366
+ @attributes = attributes.with_indifferent_access
367
+ freeze
368
+ end
369
+
370
+ def to_sym
371
+ enum_constant.to_sym
372
+ end
373
+
374
+ alias id ordinal
375
+
376
+ def read_attribute(attr)
377
+ case attr.to_s
378
+ when 'id'
379
+ ordinal
380
+ when self.class.name_attr.to_s
381
+ enum_constant
382
+ else
383
+ @attributes[attr]
384
+ end
385
+ end
386
+
387
+ alias [] read_attribute
388
+
389
+ def method_missing(meth, *args)
390
+ if attributes.has_key?(meth)
391
+ unless args.empty?
392
+ raise ArgumentError.new("wrong number of arguments (#{args.size} for 0)")
393
+ end
394
+
395
+ attributes[meth]
396
+ else
397
+ super
398
+ end
399
+ end
400
+
401
+ def respond_to_missing?(meth, include_private = false)
402
+ attributes.has_key?(meth) || super
403
+ end
404
+
405
+ def freeze
406
+ @ordinal.freeze
407
+ @enum_constant.freeze
408
+ @attributes.freeze
409
+ @attributes.each do |k, v|
410
+ k.freeze
411
+ v.freeze
412
+ end
413
+ super
414
+ end
415
+
416
+ def self.for_name(name_attr)
417
+ Class.new(self) do
418
+ define_singleton_method(:name_attr, -> { name_attr })
419
+ alias_method name_attr, :enum_constant
420
+ end
421
+ end
422
+ end
423
+
424
+ def build_dummy_class(model, name_attr)
425
+ dummy_model = PersistentEnum.dummy_class(model, name_attr)
426
+ return dummy_model if dummy_model.present?
427
+
428
+ dummy_model = AbstractDummyModel.for_name(name_attr)
429
+ model.const_set(:DummyModel, dummy_model)
430
+ dummy_model
431
+ end
432
+ end
433
+
434
+ ActiveRecord::Base.send(:include, self)
435
+ end
436
+
437
+ require 'persistent_enum/railtie' if defined?(Rails)