# rubocop:disable Naming/MethodParameterName module Ecoportal module API module Common module Content module HashDiffPatch extend DocHelpers ID_KEYS = %w[id].freeze META_KEYS = %w[id patch_ver].freeze NO_CHANGES = "%not-changed!%".freeze class << self # The `patch data` is built as follows: # 1. detect changes that have occurred translate into one `operation` of `OP_TYPE`: # * `changed`: meaning that the object has changed (existed and has not been removed) # * `delete`: the object has been removed # * `new`: the object is new # 2. at the level of the target object of the model, the object is opened for change # with `id` and `operation` as follows: # # ```json # { # "id": "objectID", # "operation": "OP_TYPE", # "data": { # "patch_ver": "prev_patch_ver_+1", # "property": "value", # "...": "..." # } # } # ``` # 3. the `data` property holds the specific changes of the object # - the `patch_ver` (compulsory) is **incremental** (for data integrity) # - the properties that have changed # @note # * there should not be difference between `null` and `""` (empty string) # @param a [Hash] current hash model # @param b [Hash] previous hash model # @return [Hash] a `patch data` def patch_diff(a, b) if b.is_a?(Hash) && !empty?(b) && empty?(a) patch_delete(b) elsif a.is_a?(Hash) && !empty?(a) && empty?(b) patch_new(a) elsif a.is_a?(Hash) && b.is_a?(Hash) patch_update(a, b) elsif any_array?(a, b) patch_data_array(a, b) else a end end private def equal_values?(a, b) if a.is_a?(String) || b.is_a?(String) a_empty = a.to_s.strip.empty? b_empty = b.to_s.strip.empty? return true if a_empty && b_empty end a == b end # Compares `a` as carrying changes of `b` # @return [Hash] patch data object with only changes def patch_data(a, b = nil, delete: false) {}.tap do |data_hash| if delete patch_ver = (a && a['patch_ver']) || 1 data_hash['patch_ver'] = patch_ver next end a.each do |key, a_value| next if META_KEYS.include?(key) b_value = nil if b&.key?(key) b_value = b[key] next if equal_values?(a_value, b_value) end data_hash[key] = patch_diff(a_value, b_value) data_hash.delete(key) if data_hash[key] == NO_CHANGES end #if (data_hash.keys - ID_KEYS).empty? return NO_CHANGES if (data_hash.keys - META_KEYS).empty? if b&.key?('patch_ver') data_hash['patch_ver'] = b['patch_ver'] elsif a&.key?('patch_ver') data_hash['patch_ver'] = a['patch_ver'] end data_hash.delete("force_patch") end end def patch_delete(b) return NO_CHANGES unless b.is_a?(Hash) return nil unless (id = get_id(b, exception: false)) { 'id' => id, 'operation' => 'deleted', 'data' => patch_data(b, delete: true) } end def patch_new(a) return NO_CHANGES unless a.is_a?(Hash) return a unless (id = get_id(a, exception: false)) { 'id' => id, 'operation' => 'new', 'data' => patch_data(a) } end def patch_update(a, b) return NO_CHANGES unless a.is_a?(Hash) return a unless (id = get_id(a, exception: false)) { 'id' => id, 'operation' => 'changed', 'data' => patch_data(a, b) }.tap do |update_hash| return nil unless update_hash['data'] != NO_CHANGES end end def patch_data_array(a, b) return patch_data_nested_array(a, b) if nested_array?(a, b) patch_data_flat_array(a, b) end def patch_data_flat_array(a, b) original_b = b a ||= [] b ||= [] same_elements = a.length == b.length && (a & b).length == b.length return a unless same_elements return NO_CHANGES if original_b a end def patch_data_nested_array(a, b) a ||= [] b ||= [] # array with nested elements a_ids = array_ids(a) b_ids = array_ids(b) del_ids = b_ids - a_ids oth_ids = b_ids & a_ids new_ids = a_ids - b_ids arr_delete = del_ids.map do |id| patch_delete(array_id_item(b, id)) end.compact arr_update = oth_ids.map do |id| patch_update(array_id_item(a, id), array_id_item(b, id)) end.compact arr_new = new_ids.map do |id| patch_new(array_id_item(a, id)) end.compact arr_delete.concat(arr_update).concat(arr_new).tap do |patch_array| # remove data with no `id` patch_array.select! {|item| item.is_a?(Hash)} return NO_CHANGES if patch_array.empty? end end def nested_array?(*arr) if arr.length > 1 arr.any? {|a| nested_array?(a)} elsif arr.length == 1 arr = arr.first || [] arr.any? do |item| next false unless item.is_a?(Hash) next true if item.key?('patch_ver') # next true if item.key?('id') false end else false end end def any_array?(a, b) [a, b].any? {|item| item.is_a?(Array)} end def empty?(a) bool = !a bool ||= a.respond_to?(:empty?) && a.empty? bool end end end end end end end # rubocop:enable Naming/MethodParameterName