[Feature][ActiveSupport] Hash#flatten_keys

Previous Topic Next Topic
 
classic Classic list List threaded Threaded
2 messages Options
Reply | Threaded
Open this post in threaded view
|

[Feature][ActiveSupport] Hash#flatten_keys

Stephen Margheim
Across numerous applications and gems, my team and I have frequently needed to be able to flatten a nested hash. The most common kind of task involves reading an arbitrarily deep, loosely structured hash (e.g. imagine an AST hash). Since the exact structure of the hash is unknown beforehand, flattening the hash allows for straightforward reading/parsing.

Here's an example from one of our tools. We have a generic `filter` method that we can include into ActiveRecord models. You pass the filtering conditions as a structured hash:

{
    attribute: 'value',
    association: {
        field: 'value'
    },
    'computed_attribute(>)': 14
}

In order to build the appropriate ActiveRecord query, we need to parse this hash, but we can't predict its shape. Here is a somewhat simplified example of the code that uses Hash#flatten_keys:

def filter(instructions_hash)
  instructions_hash.flatten_keys.reduce(self) do |relation, (attribute_path, value)|
    associations_array = attribute_path.slice(0, attribute_path.length - 1)
    attribute_model = associations_array
                        .reduce(relation) do |obj, assoc|
                          obj.reflections[assoc.to_s]&.klass
                        end
    associations_hash = associations_array
                          .reverse
                          .reduce({}) do |hash, association|
                            { association => hash }
                          end
    instruction_operator = attribute_path.last[/\((.*?)\)/, 1] || '='
    instruction_attribute = attribute_path.last.sub([/\((.*?)\)/, '')

    relation.eager_load(associations_hash)
            .where(
              Arel::Nodes::InfixOperation.new(
                instruction_operator,
                Arel::Table.new(attribute_model.table_name)[instruction_attribute],
                Arel.sql(ActiveRecord::Base.connection.quote(value))
              )
            )
  end
end

If you can implement a method like this without flattening the hash, I would genuinely be interested to see the code. Assuming for the time being, however, that situations like the above (needing to read/parse a hash of unknown depth or specific keypaths) are common enough and that no easily implementable viable alternative exists to flattening the hash, I propose adding Hash#flatten_keys to ActiveSupport.

I have a performant implementation ready for a PR if others think this is a good idea. I was imagining for the time being that a simple function would flatten the nested keys into an array key. However, it is possible to add other flatteners, like dot-separated or dash-separated or even rails-param style (e.g. a[b][c]). Also, the flattening can either flatten array values or not. If we flatten array values, the "key" put in the key path is simply the index of the item in the array.

I'm happy to get into any other specifics about implementation, but I thought it best to keep the original post fairly tightly focused on just proposing the basic idea.

--
You received this message because you are subscribed to the Google Groups "Ruby on Rails: Core" group.
To unsubscribe from this group and stop receiving emails from it, send an email to [hidden email].
To post to this group, send email to [hidden email].
Visit this group at https://groups.google.com/group/rubyonrails-core.
For more options, visit https://groups.google.com/d/optout.
Reply | Threaded
Open this post in threaded view
|

Re: [Feature][ActiveSupport] Hash#flatten_keys

Jake Niemiec

Have you looked into Hash normalization like https://github.com/chrokh/normalizr? That’s just one of many, hope it helps.

Example structure:

original_obj = {
  posts: [
    {
      id: 11,
      title: 'Relational normalization',
      author: {
        id: 22,
        name: 'Darkwing Duck'
      },
      comments: {
        id: 33,
        body: 'Interesting...'
      }
    }
  ]
}
normalized_obj = {
  posts: {
    :11 => {
      id: 11,
      title: 'Relational normalization',
      author: 22,
      comments: [33]
    }
  },
  authors: {
    :22 => {
      id: 22,
      name: 'Darkwing Duck'
    }
  },
  comments: {
    :33 => {
      body: 'Interesting...'
    }
  }
}

On Sun, Nov 11, 2018 at 8:27 PM Stephen Margheim <[hidden email]> wrote:

Across numerous applications and gems, my team and I have frequently needed to be able to flatten a nested hash. The most common kind of task involves reading an arbitrarily deep, loosely structured hash (e.g. imagine an AST hash). Since the exact structure of the hash is unknown beforehand, flattening the hash allows for straightforward reading/parsing.

Here's an example from one of our tools. We have a generic `filter` method that we can include into ActiveRecord models. You pass the filtering conditions as a structured hash:

{
    attribute: 'value',
    association: {
        field: 'value'
    },
    'computed_attribute(>)': 14
}

In order to build the appropriate ActiveRecord query, we need to parse this hash, but we can't predict its shape. Here is a somewhat simplified example of the code that uses Hash#flatten_keys:

def filter(instructions_hash)
  instructions_hash.flatten_keys.reduce(self) do |relation, (attribute_path, value)|
    associations_array = attribute_path.slice(0, attribute_path.length - 1)
    attribute_model = associations_array
                        .reduce(relation) do |obj, assoc|
                          obj.reflections[assoc.to_s]&.klass
                        end
    associations_hash = associations_array
                          .reverse
                          .reduce({}) do |hash, association|
                            { association => hash }
                          end
    instruction_operator = attribute_path.last[/\((.*?)\)/, 1] || '='
    instruction_attribute = attribute_path.last.sub([/\((.*?)\)/, '')

    relation.eager_load(associations_hash)
            .where(
              Arel::Nodes::InfixOperation.new(
                instruction_operator,
                Arel::Table.new(attribute_model.table_name)[instruction_attribute],
                Arel.sql(ActiveRecord::Base.connection.quote(value))
              )
            )
  end
end

If you can implement a method like this without flattening the hash, I would genuinely be interested to see the code. Assuming for the time being, however, that situations like the above (needing to read/parse a hash of unknown depth or specific keypaths) are common enough and that no easily implementable viable alternative exists to flattening the hash, I propose adding Hash#flatten_keys to ActiveSupport.

I have a performant implementation ready for a PR if others think this is a good idea. I was imagining for the time being that a simple function would flatten the nested keys into an array key. However, it is possible to add other flatteners, like dot-separated or dash-separated or even rails-param style (e.g. a[b][c]). Also, the flattening can either flatten array values or not. If we flatten array values, the "key" put in the key path is simply the index of the item in the array.

I'm happy to get into any other specifics about implementation, but I thought it best to keep the original post fairly tightly focused on just proposing the basic idea.

--
You received this message because you are subscribed to the Google Groups "Ruby on Rails: Core" group.
To unsubscribe from this group and stop receiving emails from it, send an email to [hidden email].
To post to this group, send email to [hidden email].
Visit this group at https://groups.google.com/group/rubyonrails-core.
For more options, visit https://groups.google.com/d/optout.

--
You received this message because you are subscribed to the Google Groups "Ruby on Rails: Core" group.
To unsubscribe from this group and stop receiving emails from it, send an email to [hidden email].
To post to this group, send email to [hidden email].
Visit this group at https://groups.google.com/group/rubyonrails-core.
For more options, visit https://groups.google.com/d/optout.