mirror of
https://github.com/discourse/discourse.git
synced 2024-11-30 23:35:44 +08:00
30990006a9
This reduces chances of errors where consumers of strings mutate inputs and reduces memory usage of the app. Test suite passes now, but there may be some stuff left, so we will run a few sites on a branch prior to merging
267 lines
7.0 KiB
Ruby
267 lines
7.0 KiB
Ruby
# 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
|
|
|
|
# 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
|
|
_custom_fields.create!(
|
|
name: k,
|
|
value: v.is_a?(Hash) || field_type == :json ? v.to_json : v
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
refresh_custom_fields_from_db
|
|
end
|
|
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
|