# Copyright (c) 2008-2013 Michael Dvorkin and contributors. # # Fat Free CRM is freely distributable under the terms of MIT license. # See MIT-LICENSE file or http://www.opensource.org/licenses/mit-license.php #------------------------------------------------------------------------------ # == Schema Information # # Table name: fields # # id :integer not null, primary key # type :string(255) # field_group_id :integer # position :integer # name :string(64) # label :string(128) # hint :string(255) # placeholder :string(255) # as :string(32) # collection :text # disabled :boolean # required :boolean # maxlength :integer # created_at :datetime # updated_at :datetime # # Implementation Notes #------------------------------------------------------------------------------ # Adding database columns dynamically is not usually recommended, # and can be potentially unsafe. However, the only alternative would be static migrations, # and we want to let users add custom fields without having to restart their server. # We could not use the EAV model for performance reasons, since we need to retain the # ability to filter and search across thousands of records with potentially hundreds # of custom fields. # # We have solved all of the major issues: # # * Custom fields for view templates are dynamically generated based on normalized # database records in the 'fields' table, instead of ActiveRecord's cached attributes. # * Concurrency issues are resolved by using 'method_missing' to refresh a model's column information # when fields are added by a separate server process. # * The custom field can be renamed or deleted, but database columns are never renamed or destroyed. # A rake task can be run to purge any orphaned columns. # * Custom field types can only be changed if the database can support the transition. # For example, you can change an 'email' field to a 'string', but not to a 'datetime', # since changing the type of the database column would cause data to be lost. # class CustomField < Field delegate :table_name, to: :klass after_validation :update_column, on: :update before_create :add_column after_create :add_ransack_translation SAFE_DB_TRANSITIONS = { any: [%w[date time timestamp], %w[integer float]], one: { 'string' => 'text' } } def available_as Field.field_types.reject do |new_type, _params| db_transition_safety(as, new_type) == :unsafe end end # Extra validation that is called on this field when validation happens # obj is reference to parent object #------------------------------------------------------------------------------ def custom_validator(obj) attr = name.to_sym obj.errors.add(attr, ::I18n.t('activerecord.errors.models.custom_field.required', field: label)) if required? && obj.send(attr).blank? obj.errors.add(attr, ::I18n.t('activerecord.errors.models.custom_field.maxlength', field: label)) if (maxlength.to_i > 0) && (obj.send(attr).to_s.length > maxlength.to_i) end protected # When changing a custom field's type, it may be necessary to # change the column type in the database. This method returns # the safety of a given transition. # Returns: # :null => no transition needed # :safe => transition is safe # :unsafe => transition is unsafe #------------------------------------------------------------------------------ def db_transition_safety(old_type, new_type = as) old_col, new_col = [old_type, new_type].map { |t| column_type(t).to_s } return :null if old_col == new_col # no transition needed return :safe if SAFE_DB_TRANSITIONS[:one].any? do |start, final| old_col == start.to_s && new_col == final.to_s # one-to-one end return :safe if SAFE_DB_TRANSITIONS[:any].any? do |col_set| [old_col, new_col].all? { |c| col_set.include?(c.to_s) } # any-to-any end :unsafe # Else, unsafe. end # Generate column name for custom field. # If column name is already taken, a numeric suffix is appended. # Example column sequence: cf_custom, cf_custom_2, cf_custom_3, ... #------------------------------------------------------------------------------ def generate_column_name suffix = nil field_name = 'cf_' + label.downcase.gsub(/[^a-z0-9]+/, '_') while (final_name = [field_name, suffix].compact.join('_')) && klass.column_names.include?(final_name) suffix = (suffix || 1) + 1 end final_name end # Returns options for ActiveRecord operations #------------------------------------------------------------------------------ def column_options Field.field_types[as][:column_options] || {} end # Create a new column to hold the custom field data #------------------------------------------------------------------------------ def add_column self.name = generate_column_name if name.blank? klass.connection.add_column(table_name, name, column_type, column_options) klass.reset_column_information klass.serialize_custom_fields! end # Adds custom field translation for Ransack def add_ransack_translation I18n.backend.store_translations(Setting.locale.to_sym, ransack: { attributes: { klass.model_name.singular => { name => label } } } ) # Reset Ransack cache # Ransack::Helpers::FormBuilder.cached_searchable_attributes_for_base = {} end # Change database column type only if safe to do so # Note: columns will never be renamed or destroyed #------------------------------------------------------------------------------ def update_column if errors.empty? && db_transition_safety(as_was) == :safe klass.connection.change_column(table_name, name, column_type, column_options) klass.reset_column_information klass.serialize_custom_fields! end end ActiveSupport.run_load_hooks(:fat_free_crm_custom_field, self) end