class Object
Constants
- ENUM_TYPE_LOCK_KEY
Public Class Methods
# File lib/persistent_enum.rb, line 426 def build_dummy_class(model, name_attr) dummy_model = PersistentEnum.dummy_class(model, name_attr) return dummy_model if dummy_model.present? dummy_model = AbstractDummyModel.for_name(name_attr) model.const_set(:DummyModel, dummy_model) dummy_model end
Given an 'enum-like' table with (id, name, …) structure and a set of enum members specified as either [name, …] or {name: {attr: val, …}, …}, ensure that there is a row in the table corresponding to each name, and cache the models as constants on the model class.
When using a database such as postgresql that supports native enumerated types, can additionally specify a native enum type to use as the primary key. In this case, the required members will be added to the native type by name using `ALTER TYPE` before insertion. This ensures that enum table ids will have predictable values and can therefore be used in database level constraints.
# File lib/persistent_enum.rb, line 109 def cache_constants(model, required_members, name_attr: 'name', required_attributes: nil, sql_enum_type: nil) # normalize member specification unless required_members.is_a?(Hash) required_members = required_members.each_with_object({}) { |c, h| h[c] = {} } end # Normalize symbols name_attr = name_attr.to_s required_members = required_members.each_with_object({}) do |(name, attrs), h| h[name.to_s] = attrs.transform_keys(&:to_s) end required_attributes = required_attributes.map(&:to_s) if required_attributes # We need to cope with (a) loading this class and (b) ensuring that all the # constants are defined (if not functional) in the case that the database # isn't present yet. If no database is present, create dummy values to # populate the constants. values = begin cache_constants_in_table(model, name_attr, required_members, required_attributes, sql_enum_type) rescue EnumTableInvalid => ex # If we're running the application in any way, under no circumstances # do we want to introduce the dummy models: crash out now. Our # conservative heuristic to detect a 'safe' loading outside the # application is whether there is a current Rake task. unless Object.const_defined?(:Rake) && Rake.try(:application)&.top_level_tasks.present? raise end # Otherwise, we want to try as hard as possible to allow the # application to be initialized enough to run the Rake task (e.g. # db:migrate). log_warning("Database table initialization error for model #{model.name}, "\ 'initializing constants with dummy records instead: ' + ex.message) cache_constants_in_dummy_class(model, name_attr, required_members, required_attributes, sql_enum_type) end return cache_values(model, values, name_attr) end
# File lib/persistent_enum.rb, line 248 def cache_constants_in_dummy_class(model, name_attr, required_members, _required_attributes, sql_enum_type) dummyclass = build_dummy_class(model, name_attr) next_id = 999999999 required_members.map do |member_name, attrs| id = sql_enum_type ? member_name : (next_id += 1) dummyclass.new(id, member_name, attrs) end end
# File lib/persistent_enum.rb, line 178 def cache_constants_in_table(model, name_attr, required_members, required_attributes, sql_enum_type) begin unless model.table_exists? raise EnumTableInvalid.new("Database table for model #{model.name} doesn't exist") end rescue ActiveRecord::NoDatabaseError raise EnumTableInvalid.new("Database for model #{model.name} doesn't exist") end internal_attributes = ['id', name_attr] table_attributes = (model.column_names - internal_attributes) # If not otherwise specified, non-null attributes without defaults are required optional_attributes = model.columns.select { |col| col.null || !col.default.nil? }.map(&:name) - internal_attributes required_attributes ||= table_attributes - optional_attributes column_defaults = model.column_defaults unless (unknown_attributes = (required_attributes - table_attributes)).blank? log_warning("PersistentEnum error: required attributes #{unknown_attrs.inspect} for model #{model.name} not found in table - ignoring.") required_attributes -= unknown_attributes end unless model.connection.indexes(model.table_name).detect { |i| i.columns == [name_attr] && i.unique } raise EnumTableInvalid.new("detected missing unique index on '#{name_attr}'") end if model.connection.open_transactions > 0 # This case particularly doesn't fall back to dummy initialization as it # indicates that the PersistentEnum wasn't initialized at startup: a # silent fallback to a dummy model could go unnoticed. raise RuntimeError.new("PersistentEnum model #{model.name} detected unsafe class initialization during a transaction: aborting.") end if sql_enum_type begin ensure_sql_enum_members(model.connection, required_members.keys, sql_enum_type) rescue MissingEnumTypeError log_warning("Database enum type missing for PersistentEnum #{model.name}: falling back to default id handling") sql_enum_type = nil end end expected_rows = required_members.map do |name, attrs| attr_names = attrs.keys if (extra_attrs = (attr_names - table_attributes)).present? log_warning("PersistentEnum error: specified attributes #{extra_attrs.inspect} for model #{model.name} missing from table - ignoring.") attrs = attrs.except(*extra_attrs) end if (missing_attrs = (required_attributes - attr_names)).present? raise EnumTableInvalid.new("enum member error: required attributes #{missing_attrs.inspect} not provided") end new_attrs = attrs.dup new_attrs[name_attr] = name new_attrs['id'] = name if sql_enum_type (optional_attributes - attr_names).each do |default_attr| new_attrs[default_attr] = column_defaults[default_attr] end new_attrs end model.transaction do upsert_records(model, name_attr, expected_rows) model.where(name_attr => required_members.keys).to_a end end
Given an 'enum-like' table with (id, name, …) structure, load existing records from the database and cache them in constants on this class
# File lib/persistent_enum.rb, line 152 def cache_records(model, name_attr: :name) if model.table_exists? values = model.scoped cache_values(model, values, name_attr) else puts "Database table for model #{model.name} doesn't exist, no constants cached." end rescue ActiveRecord::NoDatabaseError puts "Database for model #{model.name} doesn't exist, no constants cached." end
Set each value as a constant on this class. If reinitializing, only replace if the enum constant has changed, otherwise update attributes as necessary.
# File lib/persistent_enum.rb, line 321 def cache_values(model, values, name_attr) values.map do |value| constant_name = constant_name(value.read_attribute(name_attr)) if model.const_defined?(constant_name, false) existing_value = model.const_get(constant_name, false) if existing_value.is_a?(AbstractDummyModel) || existing_value.read_attribute(name_attr) != value.read_attribute(name_attr) then # Replace with new value model.send(:remove_const, constant_name) model.const_set(constant_name, value) elsif existing_value.attributes != value.attributes existing_value.reload.freeze else existing_value end else model.const_set(constant_name, value) end end end
# File lib/persistent_enum.rb, line 344 def constant_name(member_name) value = member_name.strip.gsub(/[^\w\s-]/, '_').underscore return nil if value.blank? value.gsub!(/\s+/, '_') value.gsub!(/_{2,}/, '_') value.upcase! value end
# File lib/persistent_enum.rb, line 163 def dummy_class(model, name_attr) if model.const_defined?(:DummyModel, false) dummy_class = model::DummyModel unless dummy_class.superclass == AbstractDummyModel && dummy_class.name_attr == name_attr raise NameError.new("PersistentEnum dummy class type mismatch: '#{dummy_class.inspect}' does not match '#{model.name}'") end dummy_class else nil end end
# File lib/persistent_enum.rb, line 292 def ensure_sql_enum_members(connection, names, sql_enum_type) # It may be the case that an enum type doesn't yet exist despite the table # existing, for example if the table is presently being migrated to an # enum type. If this is the case, warn and fall back to standard id handling. type_exists = ActiveRecord::Base.connection.select_value( "SELECT true FROM pg_type WHERE typname = #{connection.quote(sql_enum_type)}") raise MissingEnumTypeError.new unless type_exists # ALTER TYPE may not be performed within a transaction: obtain a # session-level advisory lock to prevent racing. begin connection.execute("SELECT pg_advisory_lock(#{ENUM_TYPE_LOCK_KEY})") quoted_type = connection.quote_table_name(sql_enum_type) current_members = connection.select_values(<<~SQL) SELECT unnest(enum_range(null::#{quoted_type}, null::#{quoted_type})); SQL (names - current_members).each do |name| connection.execute("ALTER TYPE #{quoted_type} ADD VALUE #{connection.quote(name)}") end ensure connection.execute("SELECT pg_advisory_unlock(#{ENUM_TYPE_LOCK_KEY})") end end
# File lib/persistent_enum.rb, line 354 def log_warning(message) if (logger = ActiveRecord::Base.logger) logger.warn(message) else STDERR.puts(message) end end
# File lib/persistent_enum.rb, line 259 def upsert_records(model, name_attr, expected_rows) # Not all rows will have the same attributes to upsert: group and upsert by attributes expected_rows.group_by(&:keys).each do |row_attrs, rows| upsert_columns = row_attrs - [name_attr, 'id'] case model.connection.adapter_name when 'PostgreSQL' model.import!(rows, on_duplicate_key_update: { conflict_target: [name_attr], columns: upsert_columns }) when 'Mysql2' # Even for identical rows in the same order, a INSERT .. ON DUPLICATE # KEY UPDATE or INSERT IGNORE can deadlock with itself. Obtain write # locks in advance. model.lock('FOR UPDATE').order(:id).pluck(:id) if upsert_columns.present? model.import!(rows, on_duplicate_key_update: upsert_columns) else model.import!(rows, on_duplicate_key_ignore: true) end else # No upsert support: use first_or_create optimistically rows.each do |row| record = model.lock.where(name_attr => row[name_attr]).first_or_create!(row.except('id', name_attr)) record.assign_attributes(row) record.validate! record.save! if record.changed? end end end end