# frozen_string_literal: true module HasCustomFields extend ActiveSupport::Concern module Helpers def self.append_field(target, key, value, types) if target.has_key?(key) target[key] = [target[key]] if !target[key].is_a? Array target[key] << cast_custom_field(key, value, types, _return_array = false) else target[key] = cast_custom_field(key, value, types) end end CUSTOM_FIELD_TRUE ||= ['1', 't', 'true', 'T', 'True', 'TRUE'].freeze def self.get_custom_field_type(types, key) return unless types sorted_types = types.keys.select { |k| k.end_with?("*") } .sort_by(&:length) .reverse sorted_types.each do |t| return types[t] if key =~ /^#{t}/i end types[key] end def self.cast_custom_field(key, value, types, return_array = true) return value unless type = get_custom_field_type(types, key) array = nil if Array === type type = type[0] array = true if return_array end result = case type when :boolean then !!CUSTOM_FIELD_TRUE.include?(value) when :integer then value.to_i when :json then parse_json_value(value, key) else value end array ? [result] : result end def self.parse_json_value(value, key) ::JSON.parse(value) rescue JSON::ParserError Rails.logger.warn("Value '#{value}' for custom field '#{key}' is not json, it is being ignored.") {} end end included do has_many :_custom_fields, dependent: :destroy, class_name: "#{name}CustomField" after_save :save_custom_fields attr_accessor :preloaded_custom_fields def custom_fields_fk @custom_fields_fk ||= "#{_custom_fields.reflect_on_all_associations(:belongs_to)[0].name}_id" end # To avoid n+1 queries, use this function to retrieve lots of custom fields in one go # and create a "sideloaded" version for easy querying by id. def self.custom_fields_for_ids(ids, whitelisted_fields) klass = "#{name}CustomField".constantize foreign_key = "#{name.underscore}_id".to_sym result = {} return result if whitelisted_fields.blank? klass.where(foreign_key => ids, :name => whitelisted_fields) .pluck(foreign_key, :name, :value).each do |cf| result[cf[0]] ||= {} append_custom_field(result[cf[0]], cf[1], cf[2]) end result end def self.append_custom_field(target, key, value) HasCustomFields::Helpers.append_field(target, key, value, @custom_field_types) end def self.register_custom_field_type(name, type) @custom_field_types ||= {} @custom_field_types[name] = type end def self.get_custom_field_type(name) @custom_field_types ||= {} @custom_field_types[name] end def self.preload_custom_fields(objects, fields) if objects.present? map = {} empty = {} fields.each do |field| empty[field] = nil end objects.each do |obj| map[obj.id] = obj obj.preloaded_custom_fields = empty.dup end fk = (name.underscore << "_id") "#{name}CustomField".constantize .where("#{fk} in (?)", map.keys) .where("name in (?)", fields) .pluck(fk, :name, :value).each do |id, name, value| preloaded = map[id].preloaded_custom_fields if preloaded[name].nil? preloaded.delete(name) end HasCustomFields::Helpers.append_field(preloaded, name, value, @custom_field_types) end end end end def reload(options = nil) clear_custom_fields super end def custom_field_preloaded?(name) @preloaded_custom_fields && @preloaded_custom_fields.key?(name) end def clear_custom_fields @custom_fields = nil @custom_fields_orig = nil end class PreloadedProxy def initialize(preloaded) @preloaded = preloaded end def [](key) if @preloaded.key?(key) @preloaded[key] else # for now you can not mix preload an non preload, it better just to fail raise StandardError, "Attempting to access a non preloaded custom field, this is disallowed to prevent N+1 queries." end end end def custom_fields if @preloaded_custom_fields return @preloaded_proxy ||= PreloadedProxy.new(@preloaded_custom_fields) end @custom_fields ||= refresh_custom_fields_from_db.dup end def custom_fields=(data) custom_fields.replace(data) end def custom_fields_clean? # Check whether the cached version has been changed on this model !@custom_fields || @custom_fields_orig == @custom_fields end # `upsert_custom_fields` will only insert/update existing fields, and will not # delete anything. It is safer under concurrency and is recommended when # you just want to attach fields to things without maintaining a specific # set of fields. def upsert_custom_fields(fields) fields.each do |k, v| row_count = _custom_fields.where(name: k).update_all(value: v) if row_count == 0 _custom_fields.create!(name: k, value: v) end custom_fields[k] = v end end def save_custom_fields(force = false) if force || !custom_fields_clean? dup = @custom_fields.dup array_fields = {} ActiveRecord::Base.transaction do _custom_fields.reload.each do |f| if dup[f.name].is_a?(Array) # we need to collect Arrays fully before we can compare them if !array_fields.has_key?(f.name) array_fields[f.name] = [f] else array_fields[f.name] << f end elsif dup[f.name].is_a?(Hash) if dup[f.name].to_json != f.value f.destroy! else dup.delete(f.name) end else t = {} self.class.append_custom_field(t, f.name, f.value) if dup[f.name] != t[f.name] f.destroy! else dup.delete(f.name) end end end # let's iterate through our arrays and compare them array_fields.each do |field_name, fields| if fields.length == dup[field_name].length && fields.map(&:value) == dup[field_name] dup.delete(field_name) else fields.each(&:destroy!) end end dup.each do |k, v| field_type = self.class.get_custom_field_type(k) if v.is_a?(Array) && field_type != :json v.each { |subv| _custom_fields.create!(name: k, value: subv) } else create_singular(k, v, field_type) end end end refresh_custom_fields_from_db end end # We support unique indexes on certain fields. In the event two concurrenct processes attempt to # update the same custom field we should catch the error and perform an update instead. def create_singular(name, value, field_type = nil) write_value = value.is_a?(Hash) || field_type == :json ? value.to_json : value write_value = 't' if write_value.is_a?(TrueClass) write_value = 'f' if write_value.is_a?(FalseClass) row_count = DB.exec(<<~SQL, name: name, value: write_value, id: id, now: Time.zone.now) INSERT INTO #{_custom_fields.table_name} (#{custom_fields_fk}, name, value, created_at, updated_at) VALUES (:id, :name, :value, :now, :now) ON CONFLICT DO NOTHING SQL _custom_fields.where(name: name).update_all(value: write_value) if row_count == 0 end protected def refresh_custom_fields_from_db target = Hash.new _custom_fields.order('id asc').pluck(:name, :value).each do |key, value| self.class.append_custom_field(target, key, value) end @custom_fields_orig = target @custom_fields = @custom_fields_orig.dup end end