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
collect_authorized_scopes(mappings, user) click to toggle source
# File lib/deep_unrest.rb, line 696
def self.collect_authorized_scopes(mappings, user)
  mappings.each do |mapping|
    mapping[:scope] = DeepUnrest.authorization_strategy.get_authorized_scope(user, mapping[:klass])
  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