Skip to content

Commit

Permalink
Merge pull request #272 from msievers/deep_locate
Browse files Browse the repository at this point in the history
Hashie::Extensions::DeepLocate
  • Loading branch information
dblock committed Feb 3, 2015
2 parents dc3bc1a + b3c123c commit 118eeaf
Show file tree
Hide file tree
Showing 6 changed files with 266 additions and 21 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* [#261](https://github.com/intridea/hashie/pull/261): Fixed bug where Dash.property modifies argument object - [@d_tw](https://github.com/d_tw).
* [#264](https://github.com/intridea/hashie/pull/264): Methods such as abc? return true/false with Hashie::Extensions::MethodReader - [@Zloy](https://github.com/Zloy).
* [#269](https://github.com/intridea/hashie/pull/269): Add #extractable_options? so ActiveSupport Array#extract_options! can extract it - [@ridiculous](https://github.com/ridiculous).
* [#269](https://github.com/intridea/hashie/pull/272): Added Hashie::Extensions::DeepLocate - [@msievers](https://github.com/msievers).
* Your contribution here.

## 3.3.2 (11/26/2014)
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,48 @@ user.deep_find_all(:name) #=> [{ first: 'Bob', last: 'Boberts' }, 'Rubyists', 'O
user.deep_select(:name) #=> [{ first: 'Bob', last: 'Boberts' }, 'Rubyists', 'Open source enthusiasts']
```

### DeepLocate

This extension can be mixed in to provide a depth first search based search for enumerables matching a given comparator callable.

It returns all enumerables which contain at least one element, for which the given comparator returns ```true```.

Because the container objects are returned, the result elements can be modified in place. This way, one can perform modifications on deeply nested hashes without the need to know the exact paths.

```ruby

books = [
{
title: "Ruby for beginners",
pages: 120
},
{
title: "CSS for intermediates",
pages: 80
},
{
title: "Collection of ruby books",
books: [
{
title: "Ruby for the rest of us",
pages: 576
}
]
}
]

books.extend(Hashie::Extensions::DeepLocate)

# for ruby 1.9 leave *no* space between the lambda rocket and the braces
# http://ruby-journal.com/becareful-with-space-in-lambda-hash-rocket-syntax-between-ruby-1-dot-9-and-2-dot-0/

books.deep_locate -> (key, value, object) { key == :title && value.include?("Ruby") }
# => [{:title=>"Ruby for beginners", :pages=>120}, {:title=>"Ruby for the rest of us", :pages=>576}]

books.deep_locate -> (key, value, object) { key == :pages && value <= 120 }
# => [{:title=>"Ruby for beginners", :pages=>120}, {:title=>"CSS for intermediates", :pages=>80}]
```

## Mash

Mash is an extended Hash that gives simple pseudo-object functionality that can be built from hashes and easily extended. It is intended to give the user easier access to the objects within the Mash through a property-like syntax, while still retaining all Hash functionality.
Expand Down
1 change: 1 addition & 0 deletions lib/hashie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ module Extensions
autoload :SymbolizeKeys, 'hashie/extensions/symbolize_keys'
autoload :DeepFetch, 'hashie/extensions/deep_fetch'
autoload :DeepFind, 'hashie/extensions/deep_find'
autoload :DeepLocate, 'hashie/extensions/deep_locate'
autoload :PrettyInspect, 'hashie/extensions/pretty_inspect'
autoload :KeyConversion, 'hashie/extensions/key_conversion'
autoload :MethodAccessWithOverride, 'hashie/extensions/method_access'
Expand Down
25 changes: 4 additions & 21 deletions lib/hashie/extensions/deep_find.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,32 +27,15 @@ def deep_find_all(key)
private

def _deep_find(key, object = self)
if object.respond_to?(:key?)
return object[key] if object.key?(key)

reduce_to_match(key, object.values)
elsif object.is_a?(Enumerable)
reduce_to_match(key, object)
end
_deep_find_all(key, object).first
end

def _deep_find_all(key, object = self, matches = [])
if object.respond_to?(:key?)
matches << object[key] if object.key?(key)
object.values.each { |v| _deep_find_all(key, v, matches) }
elsif object.is_a?(Enumerable)
object.each { |v| _deep_find_all(key, v, matches) }
deep_locate_result = Hashie::Extensions::DeepLocate.deep_locate(key, object).tap do |result|
result.map! { |element| element[key] }
end

matches
end

def reduce_to_match(key, enumerable)
enumerable.reduce(nil) do |found, value|
return found if found

_deep_find(key, value)
end
matches.concat(deep_locate_result)
end
end
end
Expand Down
94 changes: 94 additions & 0 deletions lib/hashie/extensions/deep_locate.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
module Hashie
module Extensions
module DeepLocate
# The module level implementation of #deep_locate, incase you do not want
# to include/extend the base datastructure. For further examples please
# see #deep_locate.
#
# @example
# books = [
# {
# title: "Ruby for beginners",
# pages: 120
# },
# ...
# ]
#
# Hashie::Extensions::DeepLocate.deep_locate -> (key, value, object) { key == :title }, books
# # => [{:title=>"Ruby for beginners", :pages=>120}, ...]
def self.deep_locate(comparator, object)
# ensure comparator is a callable
unless comparator.respond_to?(:call)
comparator = lambda do |non_callable_object|
->(key, _, _) { key == non_callable_object }
end.call(comparator)
end

_deep_locate(comparator, object)
end

# Performs a depth-first search on deeply nested data structures for a
# given comparator callable and returns each Enumerable, for which the
# callable returns true for at least one the its elements.
#
# @example
# books = [
# {
# title: "Ruby for beginners",
# pages: 120
# },
# {
# title: "CSS for intermediates",
# pages: 80
# },
# {
# title: "Collection of ruby books",
# books: [
# {
# title: "Ruby for the rest of us",
# pages: 576
# }
# ]
# }
# ]
#
# books.extend(Hashie::Extensions::DeepLocate)
#
# # for ruby 1.9 leave *no* space between the lambda rocket and the braces
# # http://ruby-journal.com/becareful-with-space-in-lambda-hash-rocket-syntax-between-ruby-1-dot-9-and-2-dot-0/
#
# books.deep_locate -> (key, value, object) { key == :title && value.include?("Ruby") }
# # => [{:title=>"Ruby for beginners", :pages=>120}, {:title=>"Ruby for the rest of us", :pages=>576}]
#
# books.deep_locate -> (key, value, object) { key == :pages && value <= 120 }
# # => [{:title=>"Ruby for beginners", :pages=>120}, {:title=>"CSS for intermediates", :pages=>80}]
def deep_locate(comparator)
Hashie::Extensions::DeepLocate.deep_locate(comparator, self)
end

private

def self._deep_locate(comparator, object, result = [])
if object.is_a?(::Enumerable)
if object.any? do |value|
if object.is_a?(::Hash)
key, value = value
else
key = nil
end

comparator.call(key, value, object)
end
result.push object
else
(object.respond_to?(:values) ? object.values : object.entries).each do |value|
_deep_locate(comparator, value, result)
end
end
end

result
end
end
end
end
124 changes: 124 additions & 0 deletions spec/hashie/extensions/deep_locate_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
require 'spec_helper'

describe Hashie::Extensions::DeepLocate do
let(:hash) do
{
from: 0,
size: 25,
query: {
bool: {
must: [
{
query_string: {
query: 'foobar',
default_operator: 'AND',
fields: [
'title^2',
'_all'
]
}
},
{
match: {
field_1: 'value_1'
}
},
{
range: {
lsr09: {
gte: 2014
}
}
}
],
should: [
{
match: {
field_2: 'value_2'
}
}
],
must_not: [
{
range: {
lsr10: {
gte: 2014
}
}
}
]
}
}
}
end

describe '.deep_locate' do
context 'if called with a non-callable comparator' do
it 'creates a key comparator on-th-fly' do
expect(described_class.deep_locate(:lsr10, hash)).to eq([hash[:query][:bool][:must_not][0][:range]])
end
end

it 'locates enumerables for which the given comparator returns true for at least one element' do
examples = [
[
->(key, _value, _object) { key == :fields },
[
hash[:query][:bool][:must].first[:query_string]
]
],
[
->(_key, value, _object) { value.is_a?(String) && value.include?('value') },
[
hash[:query][:bool][:must][1][:match],
hash[:query][:bool][:should][0][:match]
]
],
[
lambda do |_key, _value, object|
object.is_a?(Array) &&
!object.extend(described_class).deep_locate(:match).empty?
end,
[
hash[:query][:bool][:must],
hash[:query][:bool][:should]
]
]
]

examples.each do |comparator, expected_result|
expect(described_class.deep_locate(comparator, hash)).to eq(expected_result)
end
end

it 'returns an empty array if nothing was found' do
expect(described_class.deep_locate(:muff, foo: 'bar')).to eq([])
end
end

context 'if extending an existing object' do
let(:extended_hash) do
hash.extend(described_class)
end

it 'adds #deep_locate' do
expect(extended_hash.deep_locate(:bool)).to eq([hash[:query]])
end
end

context 'if included in a hash' do
let(:derived_hash_with_extension_included) do
Class.new(Hash) do
include Hashie::Extensions::DeepLocate
end
end

let(:instance) do
derived_hash_with_extension_included.new.update(hash)
end

it 'adds #deep_locate' do
expect(instance.deep_locate(:bool)).to eq([hash[:query]])
end
end
end

0 comments on commit 118eeaf

Please sign in to comment.