persistent_enum 1.2.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_record'
5
+
6
+ module PersistentEnum
7
+ module ActsAsEnum
8
+ extend ActiveSupport::Concern
9
+
10
+ class State
11
+ attr_reader :enum_spec, :by_name, :by_name_insensitive, :by_ordinal, :required_by_ordinal
12
+ delegate :required_members, :name_attr, :sql_enum_type, to: :enum_spec
13
+
14
+ def initialize(enum_spec, enum_values, required_enum_constants)
15
+ @enum_spec = enum_spec
16
+
17
+ enum_values.each do |val|
18
+ val.attributes.each_value { |attr| attr.freeze }
19
+ val.freeze
20
+ end
21
+
22
+ @by_ordinal = enum_values.index_by(&:ordinal).freeze
23
+
24
+ @by_name = enum_values
25
+ .index_by { |v| v.read_attribute(enum_spec.name_attr) }
26
+ .with_indifferent_access
27
+ .freeze
28
+
29
+ @by_name_insensitive = enum_values
30
+ .index_by { |v| v.read_attribute(enum_spec.name_attr).downcase }
31
+ .with_indifferent_access
32
+ .freeze
33
+
34
+ @required_by_ordinal = required_enum_constants
35
+ .map { |name| by_name.fetch(name) }
36
+ .index_by(&:ordinal)
37
+ .freeze
38
+
39
+ @insensitive_lookup = (by_name.size == by_name_insensitive.size)
40
+
41
+ freeze
42
+ end
43
+
44
+ def insensitive_lookup?
45
+ @insensitive_lookup
46
+ end
47
+ end
48
+
49
+ module ClassMethods
50
+ # Overridden in singleton classes to close over acts-as-enum state in an
51
+ # subclassing-safe way.
52
+ def _acts_as_enum_state
53
+ nil
54
+ end
55
+
56
+ def initialize_acts_as_enum(enum_spec)
57
+ prev_state = _acts_as_enum_state
58
+
59
+ if self.methods(false).include?(:_acts_as_enum_state)
60
+ singleton_class.class_eval do
61
+ remove_method(:_acts_as_enum_state)
62
+ end
63
+ end
64
+
65
+ ActsAsEnum.register_acts_as_enum(self) if prev_state.nil?
66
+
67
+ required_values = PersistentEnum.cache_constants(
68
+ self,
69
+ enum_spec.required_members,
70
+ name_attr: enum_spec.name_attr,
71
+ sql_enum_type: enum_spec.sql_enum_type)
72
+
73
+ required_enum_constants = required_values.map { |val| val.read_attribute(enum_spec.name_attr) }
74
+
75
+ # Now we've ensured that our required constants are present, load the rest
76
+ # of the enum from the database (if present)
77
+ all_values = required_values.dup
78
+ begin
79
+ if table_exists?
80
+ all_values.concat(unscoped { where.not(id: required_values) })
81
+ end
82
+ rescue ActiveRecord::NoDatabaseError
83
+ # Nothing additional to cache.
84
+ end
85
+
86
+ # Normalize values: If we already have a equal value in the previous
87
+ # state, we want to use that rather than a new copy of it
88
+ all_values.map! do |value|
89
+ if prev_state.present? && (prev_value = prev_state.by_name[name]) == value
90
+ prev_value
91
+ else
92
+ value
93
+ end
94
+ end
95
+
96
+ state = State.new(enum_spec, all_values, required_enum_constants)
97
+
98
+ singleton_class.class_eval do
99
+ define_method(:_acts_as_enum_state) { state }
100
+ end
101
+
102
+ before_destroy { raise ActiveRecord::ReadOnlyRecord }
103
+ end
104
+
105
+ def reinitialize_acts_as_enum
106
+ current_state = _acts_as_enum_state
107
+ raise "Cannot refresh acts_as_enum type #{self.name}: not already initialized!" if current_state.nil?
108
+
109
+ initialize_acts_as_enum(current_state.enum_spec)
110
+ end
111
+
112
+ def dummy_class
113
+ PersistentEnum.dummy_class(self, name_attr)
114
+ end
115
+
116
+ def [](index)
117
+ _acts_as_enum_state.by_ordinal[index]
118
+ end
119
+
120
+ def value_of(name, insensitive: false)
121
+ if insensitive
122
+ unless _acts_as_enum_state.insensitive_lookup?
123
+ raise RuntimeError.new("#{self.name} constants are case-dependent: cannot perform case-insensitive lookup")
124
+ end
125
+
126
+ _acts_as_enum_state.by_name_insensitive[name.downcase]
127
+ else
128
+ _acts_as_enum_state.by_name[name]
129
+ end
130
+ end
131
+
132
+ def value_of!(name, insensitive: false)
133
+ v = value_of(name, insensitive: insensitive)
134
+ raise NameError.new("#{self}: Invalid member '#{name}'") unless v.present?
135
+
136
+ v
137
+ end
138
+
139
+ alias with_name value_of
140
+
141
+ # Currently active ordinals
142
+ def ordinals
143
+ _acts_as_enum_state.required_by_ordinal.keys
144
+ end
145
+
146
+ # Currently active enum members
147
+ def values
148
+ _acts_as_enum_state.required_by_ordinal.values
149
+ end
150
+
151
+ def active?(member)
152
+ _acts_as_enum_state.required_by_ordinal.has_key?(member.ordinal)
153
+ end
154
+
155
+ # All ordinals, including of inactive enum members
156
+ def all_ordinals
157
+ _acts_as_enum_state.by_ordinal.keys
158
+ end
159
+
160
+ # All enum members, including inactive
161
+ def all_values
162
+ _acts_as_enum_state.by_ordinal.values
163
+ end
164
+
165
+ def name_attr
166
+ _acts_as_enum_state.name_attr
167
+ end
168
+ end
169
+
170
+ # Enum values should not be mutable: allow creation and modification only
171
+ # before the state has been initialized.
172
+ def readonly?
173
+ !self.class._acts_as_enum_state.nil?
174
+ end
175
+
176
+ def enum_constant
177
+ read_attribute(self.class.name_attr)
178
+ end
179
+
180
+ def to_sym
181
+ enum_constant.to_sym
182
+ end
183
+
184
+ def ordinal
185
+ read_attribute(:id)
186
+ end
187
+
188
+ # Is this enum member still present in the enum declaration?
189
+ def active?
190
+ self.class.active?(self)
191
+ end
192
+
193
+ class << self
194
+ @@known_enumerations = {}
195
+ LOCK = Monitor.new
196
+
197
+ def register_acts_as_enum(clazz)
198
+ LOCK.synchronize do
199
+ @@known_enumerations[clazz.name] = clazz
200
+ end
201
+ end
202
+
203
+ # Reload enumerations from the database: useful if the database contents
204
+ # may have changed (e.g. fixture loading).
205
+ def reinitialize_enumerations
206
+ LOCK.synchronize do
207
+ @@known_enumerations.each_value do |clazz|
208
+ clazz.reinitialize_acts_as_enum
209
+ end
210
+ end
211
+ end
212
+
213
+ # Ensure that all known_enumerations are loaded by resolving each name
214
+ # constant and reregistering the resulting class. Raises NameError if a
215
+ # previously-encountered type cannot be resolved.
216
+ def rerequire_known_enumerations
217
+ LOCK.synchronize do
218
+ @@known_enumerations.keys.each do |name|
219
+ new_clazz = name.safe_constantize
220
+ unless new_clazz.is_a?(Class)
221
+ raise NameError.new("Could not resolve ActsAsEnum type '#{name}' after reload")
222
+ end
223
+
224
+ register_acts_as_enum(new_clazz)
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PersistentEnum
4
+ class Railtie < Rails::Railtie
5
+ # On ActionDispatch::Reloader prepare!, ensure that registered acts_as_enums
6
+ # are eager-reloaded. This reduces the chance that they'll be reloaded during
7
+ # a transaction.
8
+ config.to_prepare do
9
+ ActsAsEnum.rerequire_known_enumerations
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PersistentEnum
4
+ VERSION = '1.2.4'
5
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'persistent_enum/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'persistent_enum'
9
+ spec.version = PersistentEnum::VERSION
10
+ spec.authors = ['iKnow']
11
+ spec.email = ['systems@iknow.jp']
12
+ spec.summary = 'Database-backed enums for Rails'
13
+ spec.description = 'Provide a database-backed enumeration between indices and symbolic values. This allows us to have a valid foreign key which behaves like a enumeration. Values are cached at startup, and cannot be changed.'
14
+ spec.license = 'BSD-2-Clause'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_dependency 'activerecord', '>= 5.0', '< 7'
22
+ spec.add_dependency 'activesupport', '>= 5.0', '< 7'
23
+
24
+ spec.add_dependency 'activerecord-import'
25
+
26
+ spec.add_development_dependency 'bundler'
27
+ spec.add_development_dependency 'rake'
28
+ spec.add_development_dependency 'rspec'
29
+
30
+ spec.add_development_dependency 'appraisal'
31
+ spec.add_development_dependency 'mysql2'
32
+ spec.add_development_dependency 'pg'
33
+ spec.add_development_dependency 'sqlite3'
34
+
35
+ spec.add_development_dependency 'byebug'
36
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file was generated by the `rspec --init` command. Conventionally, all
4
+ # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
5
+ # The generated `.rspec` file contains `--require spec_helper` which will cause
6
+ # this file to always be loaded, without a need to explicitly require it in any
7
+ # files.
8
+ #
9
+ # Given that it is always loaded, you are encouraged to keep this file as
10
+ # light-weight as possible. Requiring heavyweight dependencies from this file
11
+ # will add to the boot time of your test suite on EVERY test run, even for an
12
+ # individual file that may not need all of that loaded. Instead, consider making
13
+ # a separate helper file that requires the additional dependencies and performs
14
+ # the additional setup, and require it from the spec files that actually need
15
+ # it.
16
+ #
17
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
18
+ RSpec.configure do |config|
19
+ # rspec-expectations config goes here. You can use an alternate
20
+ # assertion/expectation library such as wrong or the stdlib/minitest
21
+ # assertions if you prefer.
22
+ config.expect_with :rspec do |expectations|
23
+ # This option will default to `true` in RSpec 4. It makes the `description`
24
+ # and `failure_message` of custom matchers include text for helper methods
25
+ # defined using `chain`, e.g.:
26
+ # be_bigger_than(2).and_smaller_than(4).description
27
+ # # => "be bigger than 2 and smaller than 4"
28
+ # ...rather than:
29
+ # # => "be bigger than 2"
30
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
31
+ end
32
+
33
+ # rspec-mocks config goes here. You can use an alternate test double
34
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
35
+ config.mock_with :rspec do |mocks|
36
+ # Prevents you from mocking or stubbing a method that does not exist on
37
+ # a real object. This is generally recommended, and will default to
38
+ # `true` in RSpec 4.
39
+ mocks.verify_partial_doubles = true
40
+ end
41
+
42
+ # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
43
+ # have no way to turn it off -- the option exists only for backwards
44
+ # compatibility in RSpec 3). It causes shared context metadata to be
45
+ # inherited by the metadata hash of host groups and examples, rather than
46
+ # triggering implicit auto-inclusion in groups with matching metadata.
47
+ config.shared_context_metadata_behavior = :apply_to_host_groups
48
+
49
+ # The settings below are suggested to provide a good initial experience
50
+ # with RSpec, but feel free to customize to your heart's content.
51
+ =begin
52
+ # This allows you to limit a spec run to individual examples or groups
53
+ # you care about by tagging them with `:focus` metadata. When nothing
54
+ # is tagged with `:focus`, all examples get run. RSpec also provides
55
+ # aliases for `it`, `describe`, and `context` that include `:focus`
56
+ # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
57
+ config.filter_run_when_matching :focus
58
+
59
+ # Allows RSpec to persist some state between runs in order to support
60
+ # the `--only-failures` and `--next-failure` CLI options. We recommend
61
+ # you configure your source control system to ignore this file.
62
+ config.example_status_persistence_file_path = "spec/examples.txt"
63
+
64
+ # Limits the available syntax to the non-monkey patched syntax that is
65
+ # recommended. For more details, see:
66
+ # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/
67
+ # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
68
+ # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode
69
+ config.disable_monkey_patching!
70
+
71
+ # This setting enables warnings. It's recommended, but in some cases may
72
+ # be too noisy due to issues in dependencies.
73
+ config.warnings = true
74
+
75
+ # Many RSpec users commonly either run the entire suite or an individual
76
+ # file, and it's useful to allow more verbose output when running an
77
+ # individual spec file.
78
+ if config.files_to_run.one?
79
+ # Use the documentation formatter for detailed output,
80
+ # unless a formatter has already been configured
81
+ # (e.g. via a command-line flag).
82
+ config.default_formatter = "doc"
83
+ end
84
+
85
+ # Print the 10 slowest examples and example groups at the
86
+ # end of the spec run, to help surface which specs are running
87
+ # particularly slow.
88
+ config.profile_examples = 10
89
+
90
+ # Run specs in random order to surface order dependencies. If you find an
91
+ # order dependency and want to debug it, you can fix the order by providing
92
+ # the seed, which is printed after each run.
93
+ # --seed 1234
94
+ config.order = :random
95
+
96
+ # Seed global randomization in this process using the `--seed` CLI option.
97
+ # Setting this allows you to use `--seed` to deterministically reproduce
98
+ # test failures related to randomization by passing the same `--seed` value
99
+ # as the one that triggered the failure.
100
+ Kernel.srand config.seed
101
+ =end
102
+ require 'support/helpers/database_helper'
103
+ config.include DatabaseHelper, :database
104
+
105
+ unless DatabaseHelper.db_env == 'postgresql'
106
+ config.filter_run_excluding :postgresql
107
+ end
108
+ end
@@ -0,0 +1,12 @@
1
+ ---
2
+ postgresql:
3
+ adapter: postgresql
4
+ database: persistent_enum_test
5
+ mysql2:
6
+ adapter: mysql2
7
+ database: persistent_enum_test
8
+ host: <%= ENV.fetch('MYSQL_HOST') { '127.0.0.1' } %>
9
+ user: <%= ENV.fetch('MYSQL_USER') { 'root' } %>
10
+ sqlite3:
11
+ adapter: sqlite3
12
+ database: ":memory:"
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'active_record'
5
+
6
+ module DatabaseHelper
7
+ def self.db_env
8
+ ENV.fetch('TEST_DATABASE_ENVIRONMENT', 'postgresql')
9
+ end
10
+
11
+ def self.initialize_database
12
+ db_config_path = File.join(File.dirname(__FILE__), '../config/database.yml')
13
+ db_config_data = File.read(db_config_path)
14
+ db_config_data = ERB.new(db_config_data).result
15
+ db_config = YAML.safe_load(db_config_data)
16
+ raise 'Test database configuration missing' unless db_config[db_env]
17
+
18
+ ActiveRecord::Base.establish_connection(db_config[db_env])
19
+
20
+ if ENV['DEBUG']
21
+ ActiveRecord::Base.logger = Logger.new(STDERR)
22
+ ActiveRecord::Base.logger.level = Logger::DEBUG
23
+ end
24
+ db_env
25
+ end
26
+
27
+ def initialize_test_models
28
+ @test_models = []
29
+ end
30
+
31
+ def create_test_model(name, columns, create_table: true, &block)
32
+ if create_table
33
+ table_name = name.to_s.pluralize
34
+ ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS #{table_name}")
35
+ ActiveRecord::Base.connection.create_table(table_name, &columns)
36
+ end
37
+
38
+ model_name = name.to_s.classify
39
+ clazz = Class.new(ActiveRecord::Base)
40
+ Object.const_set(model_name, clazz)
41
+ @test_models << clazz
42
+ clazz.primary_key = :id
43
+ clazz.class_eval(&block) if block_given?
44
+ clazz.reset_column_information
45
+ clazz
46
+ end
47
+
48
+ def destroy_test_models
49
+ @test_models.reverse_each do |m|
50
+ destroy_test_model(m)
51
+ end
52
+ @test_models.clear
53
+ end
54
+
55
+ def destroy_test_model(clazz)
56
+ if Object.const_defined?(clazz.name)
57
+ Object.send(:remove_const, clazz.name)
58
+ end
59
+
60
+ table_name = clazz.table_name
61
+ if clazz.connection.data_source_exists?(table_name)
62
+ clazz.connection.drop_table(table_name, force: :cascade)
63
+ end
64
+
65
+ ActiveSupport::Dependencies::Reference.clear!
66
+ end
67
+ end