module DeepUnrest
Update deeply nested associations wholesale
Constants
- VERSION
Public Class Methods
add_parent_scope(parent, type)
click to toggle source
verify that this is an actual association of the parent class.
# File lib/deep_unrest.rb, line 111 def self.add_parent_scope(parent, type) reflection = parent[:klass].reflect_on_association(to_assoc(type)) { base: parent[:scope], method: reflection.name } end
build_mutation_body(ops, scopes, user)
click to toggle source
# File lib/deep_unrest.rb, line 409 def self.build_mutation_body(ops, scopes, user) err_path_memo = {} ops.each_with_object(HashWithIndifferentAccess.new({})) do |op, memo| memo.deep_merge!(build_mutation_fragment(op, scopes, user, err_path_memo)) do |_key, a, b| if a.is_a? Array combine_arrays(a, b) else b end end end end
build_mutation_fragment(op, scopes, user, err_path_memo, rest = nil, memo = nil, cursor = nil, type = nil)
click to toggle source
# File lib/deep_unrest.rb, line 330 def self.build_mutation_fragment(op, scopes, user, err_path_memo, rest = nil, memo = nil, cursor = nil, type = nil) rest ||= parse_path(op[:path]) if rest.empty? set_action(cursor, op, type, user, scopes, err_path_memo) return memo end type, id_str = rest.shift addr = to_update_body_key(type) id = parse_id(id_str) scope_type = get_scope_type(id_str, rest.blank?, op[:destroy]) temp_id = scope_type == :create ? id_str : nil memo, next_cursor = get_mutation_cursor(memo, cursor, addr, type, id, temp_id, scope_type) next_cursor[:id] = id if id build_mutation_fragment(op, scopes, user, err_path_memo, rest, memo, next_cursor, type) end
build_redirect_regex(replacements)
click to toggle source
# File lib/deep_unrest.rb, line 535 def self.build_redirect_regex(replacements) replacements ||= [] replace_ops = replacements.map do |k, v| proc { |str| str.sub(k.to_s, v.to_s) } end proc do |str| replace_ops.each { |op| str = op.call(str) } str end end
collect_action_scopes(operation)
click to toggle source
# File lib/deep_unrest.rb, line 181 def self.collect_action_scopes(operation) resources = parse_path(operation[:path]) resources.each_with_object([]) do |(type, id), memo| validate_association(memo.last, type) scope_type = get_scope_type(id, memo.size == resources.size - 1, operation[:destroy]) scope = get_scope(scope_type, memo, type, id) context = { type: type, scope_type: scope_type, scope: scope, klass: to_class(type), error_path: operation[:errorPath], id: id } context[:path] = operation[:path] unless scope_type == :show memo.push(context) end end
collect_all_scopes(params)
click to toggle source
# File lib/deep_unrest.rb, line 201 def self.collect_all_scopes(params) idx = {} params.map { |operation| collect_action_scopes(operation) } .flatten .each_with_object({}) do |op, memo| # ensure no duplicate scopes memo["#{op[:scope_type]}-#{op[:type]}-#{op[:id]}"] ||= {} memo["#{op[:scope_type]}-#{op[:type]}-#{op[:id]}"].merge!(op) end.values .map do |op| unless op[:scope_type] == :show op[:index] = update_indices(idx, op[:type])[op[:type]] - 1 end op end end
combine_arrays(a, b)
click to toggle source
# File lib/deep_unrest.rb, line 356 def self.combine_arrays(a, b) # get list of items duped by id groups = (a + b).flatten.group_by { |item| item[:id] } dupes = groups.select { |_, v| v.size > 1 }.values # filter non-dupe non_dupes = groups.select { |_, v| v.size == 1 }.values # recrsively merge dupes merged = dupes.map do |(a2, b2)| a2.deep_merge(b2) do |_, a3, b3| if a3.is_a? Array combine_arrays(a3, b3) else b3 end end end # add merged dupes to non-dupes (non_dupes + merged).flatten end
configure() { |self| ... }
click to toggle source
# File lib/deep_unrest/engine.rb, line 29 def self.configure(&_block) yield self end
convert_temp_ids!(ctx, mutations)
click to toggle source
# File lib/deep_unrest.rb, line 309 def self.convert_temp_ids!(ctx, mutations) case mutations when Hash mutations.keys.map do |key| val = mutations[key] if ['id', :id].include?(key) unless parse_id(val) mutations.delete(key) mutations[:deep_unrest_temp_id] = val mutations[:deep_unrest_context] = ctx end else convert_temp_ids!(ctx, val) end end when Array mutations.map { |val| convert_temp_ids!(ctx, val) } end mutations end
deep_camelize_keys(query)
click to toggle source
# File lib/deep_unrest.rb, line 681 def self.deep_camelize_keys(query) query&.deep_transform_keys! do |key| k = begin key.to_s.camelize(:lower) rescue StandardError key end begin k.to_sym rescue StandardError key end end end
deep_underscore_keys(query)
click to toggle source
SHARED ###
# File lib/deep_unrest.rb, line 666 def self.deep_underscore_keys(query) query&.deep_transform_keys! do |key| k = begin key.to_s.underscore rescue StandardError key end begin k.to_sym rescue StandardError key end end end
format_error_keys(res)
click to toggle source
# File lib/deep_unrest.rb, line 548 def self.format_error_keys(res) record = res[:record] record&.errors&.messages end
format_error_title(title)
click to toggle source
handle error titles in cases where error value is an array
# File lib/deep_unrest.rb, line 484 def self.format_error_title(title) if title.is_a?(Array) title.join(', ') else title end end
format_errors(operation, path_info, values)
click to toggle source
# File lib/deep_unrest.rb, line 492 def self.format_errors(operation, path_info, values) if operation return values.map do |msg| base_path = ( operation[:error_path] || operation[:dr_error_key] || operation[:ar_error_key] ) # TODO: case field name according to jsonapi_resources settings field_name = path_info[:field].camelize(:lower) pointer = [base_path, field_name].compact.join('.') active_record_path = [operation[:ar_error_key], field_name].reject(&:empty?).compact.join('.') deep_unrest_path = [operation[:dr_error_key], field_name].compact.join('.') { title: "#{path_info[:field].humanize} #{format_error_title(msg)}", detail: msg, source: { pointer: pointer, deepUnrestPath: deep_unrest_path, activeRecordPath: active_record_path } } end end values.map do |msg| { title: msg, detail: msg, source: { pointer: nil } } end end
get_mutation_cursor(memo, cursor, addr, type, id, temp_id, scope_type)
click to toggle source
# File lib/deep_unrest.rb, line 275 def self.get_mutation_cursor(memo, cursor, addr, type, id, temp_id, scope_type) if memo record = { id: id || temp_id } if plural?(type) cursor[addr] = [record] next_cursor = cursor[addr][0] else cursor[addr] = record next_cursor = cursor[addr] end else method = scope_type == :show ? :update : scope_type cursor = {} type_sym = type.to_sym klass = to_class(type) body = {} body[klass.primary_key.to_sym] = id if id cursor[type_sym] = { klass: klass, resource: get_resource(type) } cursor[type_sym][:operations] = {} cursor[type_sym][:operations][id || temp_id] = {} cursor[type_sym][:operations][id || temp_id][method] = { method: method, body: body } cursor[type_sym][:operations][id || temp_id][method][:temp_id] = temp_id if temp_id memo = cursor next_cursor = cursor[type_sym][:operations][id || temp_id][method][:body] end [memo, next_cursor] end
get_resource(type)
click to toggle source
# File lib/deep_unrest.rb, line 68 def self.get_resource(type) "#{type.classify}Resource".constantize end
get_scope(scope_type, memo, type, id_str = nil)
click to toggle source
# File lib/deep_unrest.rb, line 130 def self.get_scope(scope_type, memo, type, id_str = nil) case scope_type when :show, :update, :destroy id = /^\.(?<id>[\w-]+)$/.match(id_str)[:id] { base: to_class(type), method: :find, arguments: [id] } when :update_all, :index if memo.empty? { base: to_class(type), method: :all } else add_parent_scope(memo[memo.size - 1], type) end when :all { base: to_class(type), method: :all } end end
get_scope_type(id, last, destroy)
click to toggle source
# File lib/deep_unrest.rb, line 72 def self.get_scope_type(id, last, destroy) case id when /^\[[\w+\-]+\]$/ :create when /^\.[\w-]+$/ if last if destroy.present? :destroy else :update end else :show end when /^\.\*$/ if last if destroy.present? :destroy_all else :update_all end else :index end else raise InvalidId, "Unknown ID format: #{id}" end end
increment_error_indices(path_info, memo)
click to toggle source
# File lib/deep_unrest.rb, line 227 def self.increment_error_indices(path_info, memo) path_info.each_with_index.map do |(type, id), i| next if i.zero? parent_type, parent_id = path_info[i - 1] key = "#{parent_type}#{parent_id}#{type}" memo[key] = [] unless memo[key] idx = memo[key].find_index(id) unless idx idx = memo[key].size memo[key] << id end "#{type.underscore}[#{idx}]" end.compact.join('.') end
map_errors_to_param_keys(scopes, ops)
click to toggle source
# File lib/deep_unrest.rb, line 519 def self.map_errors_to_param_keys(scopes, ops) ops.map do |errors| errors.map do |key, values| path_info = parse_error_path(key.to_s) operation = scopes.find do |s| ( s[:ar_error_key] && s[:ar_error_key] == (path_info[:path] || '') && s[:scope_type] != :show ) end format_errors(operation, path_info, values) end end.flatten end
mutate(mutation, context)
click to toggle source
# File lib/deep_unrest.rb, line 422 def self.mutate(mutation, context) user = context[:current_user] ActiveRecord::Base.transaction do mutation.map do |_, item| item[:operations].map do |id, ops| ops.map do |_, action| record = case action[:method] when :update_all DeepUnrest.authorization_strategy .get_authorized_scope(user, item[:klass]) .update(action[:body]) nil when :destroy_all DeepUnrest.authorization_strategy .get_authorized_scope(user, item[:klass]) .destroy_all nil when :update model = item[:klass].find(id) model.assign_attributes(action[:body]) resource = item[:resource].new(model, context) resource.run_callbacks :save do resource.run_callbacks :update do model.save model end end when :create model = item[:klass].new(action[:body]) resource = item[:resource].new(model, context) resource.run_callbacks :save do resource.run_callbacks :create do resource._model.save resource._model end end when :destroy model = item[:klass].find(id) resource = item[:resource].new(model, context) resource.run_callbacks :remove do item[:klass].destroy(id) end end result = { record: record } if action[:temp_id] result[:temp_ids] = {} result[:temp_ids][action[:temp_id]] = record.id end result end end end end end
parse_attributes(type, scope_type, attributes, user)
click to toggle source
# File lib/deep_unrest.rb, line 162 def self.parse_attributes(type, scope_type, attributes, user) p = JSONAPI::RequestParser.new resource = get_resource(type) p.source_klass = resource ctx = { current_user: user } opts = if scope_type == :create resource.creatable_fields(ctx) else resource.updatable_fields(ctx) end p.parse_params(resource, { attributes: attributes }, opts)[:attributes] rescue JSONAPI::Exceptions::ParameterNotAllowed unpermitted_keys = attributes.keys.map(&:to_sym) - opts msg = "Attributes #{unpermitted_keys} of #{type.classify} not allowed" msg += " to #{user.class} with id '#{user.id}'" if user raise UnpermittedParams, [{ title: msg }].to_json end
parse_error_path(key)
click to toggle source
# File lib/deep_unrest.rb, line 478 def self.parse_error_path(key) rx = /^(?<path>.*\])?\.?(?<field>[\w\-\.]+)$/ rx.match(key) end
parse_id(id_str)
click to toggle source
# File lib/deep_unrest.rb, line 218 def self.parse_id(id_str) return unless id_str.is_a?(String) || id_str.is_a?(Integer) return false if id_str.nil? return id_str if id_str.is_a? Integer id_match = id_str.match(/^\.?(?<id>[\w\-]+)$/) id_match && id_match[:id] end
parse_path(path)
click to toggle source
# File lib/deep_unrest.rb, line 146 def self.parse_path(path) rx = /(?<type>\w+)(?<id>(?:\[|\.)[\w+\-\*\]]+)/ result = path.scan(rx) unless result.map { |res| res.join('') }.join('.') == path raise InvalidPath, "Invalid path: #{path}" end result end
perform_read(ctx, params, user)
click to toggle source
# File lib/deep_unrest.rb, line 553 def self.perform_read(ctx, params, user) DeepUnrest::Read.read(ctx, params, user) end
perform_update(ctx, params, user)
click to toggle source
# File lib/deep_unrest.rb, line 603 def self.perform_update(ctx, params, user) temp_id_map = DeepUnrest::ApplicationController.class_variable_get( '@@temp_ids' ) user = ctx[:current_user] uuid = ctx[:uuid] temp_id_map[uuid] ||= {} # reject new resources marked for destruction viable_params = params.reject do |param| temp_id?(param[:path]) && param[:destroy].present? end # identify requested scope(s) scopes = collect_all_scopes(viable_params) # authorize user for requested scope(s) DeepUnrest.authorization_strategy.authorize(scopes, user).flatten # bulid update arguments mutations = build_mutation_body(viable_params, scopes, user) # convert temp_ids from ids to non-activerecord attributes convert_temp_ids!(uuid, mutations) # perform update results = mutate(mutations, ctx).flatten # check results for errors errors = results.map { |res| format_error_keys(res) } .compact .reject(&:empty?) .compact if errors.empty? destroyed = DeepUnrest::ApplicationController.class_variable_get( '@@destroyed_entities' ) changed = DeepUnrest::ApplicationController.class_variable_get( '@@changed_entities' ) diff = serialize_changes(changed, user) return { redirect_regex: build_redirect_regex(temp_id_map[uuid]), temp_ids: temp_id_map[uuid], destroyed: destroyed.map { |d| d.except(:query_uuid) }, changed: diff } end # map errors to their sources formatted_errors = { errors: map_errors_to_param_keys(scopes, errors) } # raise error if there are any errors raise Conflict, formatted_errors.to_json unless formatted_errors.empty? end
perform_write(ctx, params, user)
click to toggle source
# File lib/deep_unrest.rb, line 557 def self.perform_write(ctx, params, user) DeepUnrest::Write.write(ctx, params, user) end
plural?(s)
click to toggle source
# File lib/deep_unrest.rb, line 105 def self.plural?(s) str = s.to_s str.pluralize == str && str.singularize != str end
serialize_changes(diffs, user)
click to toggle source
# File lib/deep_unrest.rb, line 574 def self.serialize_changes(diffs, user) ctx = { current_user: user } diffs.each do |diff| diff[:resource] = diff[:attributes] pk = diff[:klass].primary_key diff[:resource][pk] = diff[:id] diff[:model] = diff[:klass].new(diff[:resource]) end allowed_models = diffs.select do |diff| scope = DeepUnrest.authorization_strategy .get_authorized_scope(user, diff[:klass]) scope.exists?(diff[:id]) rescue NameError false end resources = allowed_models.map do |diff| resource_klass = get_resource(diff[:klass].to_s) fields = {} keys = diff[:resource].keys.map(&:to_sym) fields[to_assoc(diff[:klass].to_s.pluralize)] = keys serialize_resource(resource_klass, fields, diff[:model].id) end resources.select { |item| item.dig('attributes') }.compact end
serialize_resource(resource_klass, fields, id)
click to toggle source
# File lib/deep_unrest.rb, line 561 def self.serialize_resource(resource_klass, fields, id) resource_identity = JSONAPI::ResourceIdentity.new(resource_klass, id) id_tree = JSONAPI::PrimaryResourceIdTree.new id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(resource_identity), {}) resource_set = JSONAPI::ResourceSet.new(id_tree) serializer = JSONAPI::ResourceSerializer.new( resource_klass, fields: fields ) resource_set.populate!(serializer, {}, {}) serializer.serialize_resource_set_to_hash_single(resource_set)['data'].except('links') end
serialize_result(ctx, item)
click to toggle source
# File lib/deep_unrest.rb, line 702 def self.serialize_result(ctx, item) resource = item[:resource] resource_instance = resource.new(item[:record], ctx) fields = {} keys = item[:query][:fields].map(&:underscore).map(&:to_sym) fields[to_assoc(item[:key].pluralize)] = keys serialize_resource(resource, fields, item[:record].id).except(:links) end
set_action(cursor, operation, type, user, scopes, err_path_memo)
click to toggle source
# File lib/deep_unrest.rb, line 244 def self.set_action(cursor, operation, type, user, scopes, err_path_memo) # TODO: this is horrible. find a better way to go about this path_info = parse_path(operation[:path]) id_str = path_info.last[1] id = parse_id(id_str) action = get_scope_type(id_str, true, operation[:destroy]) cursor[:id] = id || id_str scope = scopes.find do |s| s[:type] == type && s[:id] == id_str end scope[:ar_error_key] = increment_error_indices(path_info, err_path_memo) scope[:dr_error_key] = path_info.map { |pair| pair.join('') }.join('.') case action when :destroy cursor[:_destroy] = true when :update, :create, :update_all cursor.merge! parse_attributes(type, operation[:action], operation[:attributes], user) end cursor end
set_attr(hash, path, val, cursor = nil)
click to toggle source
# File lib/deep_unrest.rb, line 379 def self.set_attr(hash, path, val, cursor = nil) cursor ||= hash key = path.shift key = key.to_i if cursor.is_a? Array if path.empty? case cursor when Array, Hash # only do anything if the cursor is iterable case cursor[key] when Hash # only attempt to merge if cursor[key] = (cursor[key] || {}).deep_merge(val) when Array cursor[key] = (cursor[key] || []) + val else cursor[key] = val end end return hash end next_cursor = case key when /\[\]$/ cursor[key.gsub('[]', '')] ||= [] else cursor[key] ||= {} end set_attr(hash, path, val, next_cursor) end
temp_id?(str)
click to toggle source
# File lib/deep_unrest.rb, line 101 def self.temp_id?(str) /\[[\w+\-]+\]$/.match(str) end
to_assoc(str)
click to toggle source
# File lib/deep_unrest.rb, line 60 def self.to_assoc(str) str.underscore.to_sym end
to_class(str)
click to toggle source
# File lib/deep_unrest.rb, line 56 def self.to_class(str) str.classify.constantize end
to_update_body_key(str)
click to toggle source
# File lib/deep_unrest.rb, line 64 def self.to_update_body_key(str) "#{str}Attributes".underscore.to_sym end
update_indices(indices, type)
click to toggle source
# File lib/deep_unrest.rb, line 156 def self.update_indices(indices, type) indices[type] ||= 0 indices[type] += 1 indices end
validate_association(parent, type)
click to toggle source
# File lib/deep_unrest.rb, line 116 def self.validate_association(parent, type) return unless parent reflection = parent[:klass].reflect_on_association(to_assoc(type)) raise NoMethodError unless reflection.klass == to_class(type) unless parent[:id] raise InvalidParentScope, 'Unable to update associations of collections '\ "('#{parent[:type]}.#{type}')." end rescue NoMethodError raise InvalidAssociation, "'#{parent[:type]}' has no association '#{type}'" end