# frozen_string_literal: true module Karafka module Web module Ui module Lib # Sorting engine for deep in-memory structures # It supports hashes, arrays and hash proxies. # # @note It handles sorting in place by mutating appropriate resources and sub-components class Sorter # We can support only two order types ALLOWED_ORDERS = %w[asc desc].freeze # Max depth for nested sorting MAX_DEPTH = 8 private_constant :ALLOWED_ORDERS, :MAX_DEPTH # @param sort_query [String] query for sorting or empty string if no sorting needed # @param allowed_attributes [Array] attributes on which we allow to sort. Since # we can sort on method invocations, this needs to be limited and provided on a per # controller basis. def initialize(sort_query, allowed_attributes:) field, order = sort_query.split(' ') @order = order.to_s.downcase @order = ALLOWED_ORDERS.first unless ALLOWED_ORDERS.include?(@order) # Normalize the key since we do not operate on capitalized values @field = field.to_s.downcase @field = '' unless allowed_attributes.include?(@field) # Things we have already seen and sorted. Prevents crashing on the circular # dependencies sorting when same resources are present in different parts of the tree @seen = {} end # Sorts the structure and returns it sorted. # # @param resource [Hash, Array, Lib::HashProxy] structure we want to sort # @param current_depth [] def call(resource, current_depth = 0) # Skip if there is no sort field at all return resource if @field.empty? # Skip if we've already seen this resource # We use object id instead of full object as the objects can get big return resource if @seen.key?(resource.object_id) # Skip if we are too deep return resource if current_depth > MAX_DEPTH @seen[resource.object_id] = nil case resource when Array sort_array!(resource, current_depth) when Hash sort_hash!(resource, current_depth) when Lib::HashProxy # We can short hash in place here, because it will be still references (the same) # in the hash proxy object, so we can do it that way sort_hash!(resource.to_h, current_depth) when Enumerable sort_array!(resource, current_depth) end resource end private # Sorts the hash in place # # @param hash [Hash] hash we want to sort # @param current_depth [Integer] current depth of sorting from root def sort_hash!(hash, current_depth) # Run sorting on each value, since we may have nested hashes and arrays hash.each do |key, value| previous_key = @parent_key @parent_key = key.to_s.downcase call(value, current_depth + 1) @parent_key = previous_key end # We cannot short hashes that are not type aligned. That is, we cannot compare # nested hashes with integers, etc. In some cases we could (Float vs Integer), however # for the same of simplicity, we do not to that return unless hash.values.map(&:class).uniq.size == 1 # Allows sorting based on parent key when hash contains another hash where we want to # sort based on the keys and not based on the value if @parent_key == @field # We also should not modify hashes that do not have values that are sortable # false is sortable but nil is not sorted = hash.sort_by { |key, _| key.to_s } else values = hash.values.map { |value| sortable_value(value) } return if values.any?(&:nil?) return unless values.map(&:class).uniq.size == 1 # Generate new hash that will have things in our desired order sorted = hash.sort_by { |_, value| sortable_value(value) } end sorted.reverse! if desc? # Clear our hash and inject the new values in the order in which we want to have them # Such clear and merge will ensure things are in the order we desired them hash.clear hash.merge!(sorted.to_h) end # Sorts an array in-place based on a specified attribute. # # The method iterates over each element in the array and applies the transformation. # # @param array [Array] The array of elements to be sorted # @param current_depth [Integer] The current depth of the sorting operation, # used in the `call` method to handle nested structures or recursion. # @note This method modifies the array in place (mutates the caller). def sort_array!(array, current_depth) # Sort arrays containing hashes by a specific attribute array.map! { |element| call(element, current_depth + 1) } values = array.map { |element| sortable_value(element) } return if values.any?(&:nil?) return unless values.map(&:class).uniq.size == 1 array.sort_by! { |element| sortable_value(element) } array.reverse! if desc? end # @return [Boolean] true if we sort in desc, otherwise false def desc? @order == 'desc' end # Extracts the attribute based on which we should sort (if present) # # @param element [Object] takes the element object and depending on its type, tries to # figure out the value based on which we may sort # @return [Object, nil] sortable value or nil if nothing to sort def sortable_value(element) result = nil result = element[@field] || element[@field.to_sym] if element.is_a?(Hash) result = element.public_send(@field) if element.respond_to?(@field) # We cannot sort on some of the types and some require mapping, thus we convert # types here when needed case result when Hash nil when Lib::HashProxy nil when true 1 when false 0 else result end end end end end end end