tree_filter 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/HISTORY.md +5 -0
- data/LICENSE +14 -0
- data/README.md +85 -0
- data/lib/tree_filter.rb +170 -0
- data/spec/tree_filter_spec.rb +127 -0
- data/tree_filter.gemspec +27 -0
- metadata +53 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 1e4aed6475c535ad77186d563ce4eb1aae077440
|
4
|
+
data.tar.gz: b6aaf6e676e1c2fd16022e37aea6fa47a713c828
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 155685535bc93a517363950614df705167ea5209f099fdca991fc6528788ff281bb1719ea5d64f14dcf1f1a19e3a27d109e7c23af1d7514350dfb0930a8a7233
|
7
|
+
data.tar.gz: d79a3622fb629cdccdfb1a677accd723002b43fed72a26d460d927ce28b0a5220b480eb2d0d5163ae54b081708d891d3573ac00e390df14e56e0d6d3da4d9b88
|
data/HISTORY.md
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
|
2
|
+
Copyright 2015 Square Inc.
|
3
|
+
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
you may not use this file except in compliance with the License.
|
6
|
+
You may obtain a copy of the License at
|
7
|
+
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
See the License for the specific language governing permissions and
|
14
|
+
limitations under the License.
|
data/README.md
ADDED
@@ -0,0 +1,85 @@
|
|
1
|
+
# Tree Filter
|
2
|
+
|
3
|
+
Filter arbitrary data trees with a concise query language. Similar to how the
|
4
|
+
Jenkins API works, if you happen to be familiar with that.
|
5
|
+
|
6
|
+
name,environments # Select specific attributes from a hash
|
7
|
+
environments[id,last_deploy] # Select attributes from sub-hash
|
8
|
+
environments[*] # Select all attributes
|
9
|
+
|
10
|
+
## Usage
|
11
|
+
|
12
|
+
```ruby
|
13
|
+
require 'tree_filter'
|
14
|
+
|
15
|
+
data = {
|
16
|
+
'name' => 'don',
|
17
|
+
'contact' => {
|
18
|
+
'phone' => '415-123-4567',
|
19
|
+
'email' => 'don@example.com'
|
20
|
+
}
|
21
|
+
}
|
22
|
+
|
23
|
+
TreeFilter.new("name,contact[email]").filter(data)
|
24
|
+
# => {'name' => 'don', 'contact' => {'email' => 'don@example.com'}}
|
25
|
+
```
|
26
|
+
|
27
|
+
Different data structures can be presented dependent on whether they are
|
28
|
+
explicitly expanded or not. This is typically used when referring to other
|
29
|
+
resources in an API response.
|
30
|
+
|
31
|
+
```ruby
|
32
|
+
data = {
|
33
|
+
'name' => 'don',
|
34
|
+
'contact' => TreeFilter::Leaf.new('/contact-data/1', {
|
35
|
+
'phone' => '415-123-4567',
|
36
|
+
'email' => 'don@example.com'
|
37
|
+
})
|
38
|
+
}
|
39
|
+
|
40
|
+
TreeFilter.new("*").filter(data)
|
41
|
+
# => {'name' => 'don', 'contact' => '/contact-data/1'}
|
42
|
+
|
43
|
+
TreeFilter.new("contact[*]").filter(data)
|
44
|
+
# => {'contact' => {'phone' => '415-123-4567', 'email' => 'don@example.com'}}
|
45
|
+
```
|
46
|
+
|
47
|
+
For nested data structures, evaluation can be defered until it is actually
|
48
|
+
required. This can defer resource lookups, and also allows cyclic structures!
|
49
|
+
|
50
|
+
```ruby
|
51
|
+
data = { 'name' => 'don', }
|
52
|
+
|
53
|
+
data['contact'] = TreeFilter::Leaf.new(
|
54
|
+
'/contact-data/1',
|
55
|
+
TreeFilter::Defer.new(->{{
|
56
|
+
'email' => 'don@example.com',
|
57
|
+
'person' => TreeFilter::Leaf.new('/person/1', data)
|
58
|
+
}})
|
59
|
+
)
|
60
|
+
|
61
|
+
TreeFilter.new("contact[person[contact[email]]]").filter(data)
|
62
|
+
# => {'contact' => {'person' => {'contact' => {'email' => 'don@example.com'}}}}
|
63
|
+
```
|
64
|
+
|
65
|
+
## Compatibility
|
66
|
+
|
67
|
+
All rubies that ruby core supports! Should work on JRuby and Rubinius too.
|
68
|
+
|
69
|
+
## Support
|
70
|
+
|
71
|
+
Make a [new github
|
72
|
+
issue](https://github.com/square/ruby-tree_filter/issues/new).
|
73
|
+
|
74
|
+
## Contributing
|
75
|
+
|
76
|
+
Fork and patch! Before any changes are merged to master, we need you to sign an
|
77
|
+
[Individual Contributor
|
78
|
+
Agreement](https://spreadsheets.google.com/a/squareup.com/spreadsheet/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1)
|
79
|
+
(Google Form).
|
80
|
+
|
81
|
+
Run tests:
|
82
|
+
|
83
|
+
gem install bundler
|
84
|
+
bundle
|
85
|
+
bundle exec rspec
|
data/lib/tree_filter.rb
ADDED
@@ -0,0 +1,170 @@
|
|
1
|
+
require 'stringio'
|
2
|
+
|
3
|
+
# Allows filtering of complex data-structure using a string query language.
|
4
|
+
#
|
5
|
+
# Query examples:
|
6
|
+
#
|
7
|
+
# name,environments # Select specific attributes from a hash
|
8
|
+
# environments[id,last_deploy] # Select attributes from sub-hash
|
9
|
+
# environments[*] # Select all attributes
|
10
|
+
#
|
11
|
+
# Two special objects are provided for richer data structure evaluation, Leaf
|
12
|
+
# and Defer. See their documentation respectively.
|
13
|
+
#
|
14
|
+
# More examples in unit specs.
|
15
|
+
class TreeFilter
|
16
|
+
def initialize(input)
|
17
|
+
@input = StringIO.new(input)
|
18
|
+
end
|
19
|
+
|
20
|
+
def filter(value)
|
21
|
+
slice.filter(value)
|
22
|
+
end
|
23
|
+
|
24
|
+
module JsonTerminal
|
25
|
+
# This breaks the contract of `to_json`, since only terminal classes
|
26
|
+
# (hard-coded into activesupport) should return themselves. We need to use
|
27
|
+
# `as_json` to expand other classes, however. Data structures containing
|
28
|
+
# this object should always be filtered before being converted though, so
|
29
|
+
# in practice this shouldn't be an issue.
|
30
|
+
def as_json(*args)
|
31
|
+
self
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Allows different data structures to be presented dependent on whether it is
|
36
|
+
# explicitly selected or not. Usually used to provide a "summary" object by
|
37
|
+
# default, and then only include detail if explicitly asked for.
|
38
|
+
#
|
39
|
+
# data = {'a' => TreeFilter::Leaf.new('/a', {'id' => 'a'})}
|
40
|
+
#
|
41
|
+
# TreeFilter.new("env").filter(data) # => {'env' => '/a'}
|
42
|
+
# TreeFilter.new("env[*]").filter(data) # => {'env' => {'id' => 'a'}}
|
43
|
+
#
|
44
|
+
# @note All data structures containing this object must be filtered before
|
45
|
+
# converting to JSON, otherwise you will get a stack overflow error.
|
46
|
+
Leaf = Struct.new(:left, :right) do
|
47
|
+
include JsonTerminal
|
48
|
+
end
|
49
|
+
|
50
|
+
# The wrapped lamda will not be executed unless it is actually required in
|
51
|
+
# the filtered response. This can be used for performance optimization, and
|
52
|
+
# also to break cycles.
|
53
|
+
#
|
54
|
+
# data = {'a' => 1, 'b' => TreeFilter::Defer.new(->{ raise })}
|
55
|
+
#
|
56
|
+
# TreeFilter.new("a").filter(data) # => {'a' => 1}
|
57
|
+
# TreeFilter.new("b").filter(data) # => raise
|
58
|
+
#
|
59
|
+
# @note All data structures containing this object must be filtered before
|
60
|
+
# converting to JSON, otherwise you will get a stack overflow error.
|
61
|
+
Defer = Struct.new(:f) do
|
62
|
+
include JsonTerminal
|
63
|
+
|
64
|
+
def call
|
65
|
+
f.call
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def inspect
|
70
|
+
"<TreeFilter #{slice.attrs.inspect}>"
|
71
|
+
end
|
72
|
+
private
|
73
|
+
|
74
|
+
# Indicates no more filtering for the given object, return it as is.
|
75
|
+
class NullSlice
|
76
|
+
def filter(x)
|
77
|
+
case x
|
78
|
+
when Leaf
|
79
|
+
filter(x.left)
|
80
|
+
when Defer
|
81
|
+
filter(x.call)
|
82
|
+
else
|
83
|
+
x
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
Slice = Struct.new(:attrs) do
|
89
|
+
def inspect
|
90
|
+
"<Slice #{@attrs.inspect}>"
|
91
|
+
end
|
92
|
+
|
93
|
+
def initialize(attrs = {})
|
94
|
+
super
|
95
|
+
@attrs = attrs
|
96
|
+
end
|
97
|
+
|
98
|
+
def filter(value)
|
99
|
+
# With activesupport this will always be evaluated, because `as_json` is
|
100
|
+
# monkey-patched on to Object.
|
101
|
+
value = value.as_json if value.respond_to?(:as_json)
|
102
|
+
|
103
|
+
case value
|
104
|
+
when Hash
|
105
|
+
slices = @attrs.dup
|
106
|
+
|
107
|
+
if @attrs.keys.include?('*')
|
108
|
+
slices.delete("*")
|
109
|
+
extra = value.keys - slices.keys - ['*']
|
110
|
+
extra.each do |k|
|
111
|
+
slices[k] = nil
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
slices.each_with_object({}) do |(attr, slice), ret|
|
116
|
+
slice ||= NullSlice.new
|
117
|
+
|
118
|
+
val = value[attr]
|
119
|
+
|
120
|
+
filtered = case val
|
121
|
+
when Array
|
122
|
+
val.map {|x| slice.filter(x) }
|
123
|
+
else
|
124
|
+
slice.filter(val)
|
125
|
+
end
|
126
|
+
|
127
|
+
ret[attr] = filtered
|
128
|
+
end
|
129
|
+
when Array
|
130
|
+
value.map {|x| filter(x) }
|
131
|
+
when Defer
|
132
|
+
filter(value.call)
|
133
|
+
when Leaf
|
134
|
+
filter(value.right)
|
135
|
+
else
|
136
|
+
value
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def slice
|
142
|
+
@slice ||= parse(@input)
|
143
|
+
end
|
144
|
+
|
145
|
+
def parse(input)
|
146
|
+
slices = {}
|
147
|
+
label = ""
|
148
|
+
|
149
|
+
while char = input.read(1)
|
150
|
+
case char
|
151
|
+
when ','
|
152
|
+
unless label.empty?
|
153
|
+
slices[label] = nil
|
154
|
+
label = ""
|
155
|
+
end
|
156
|
+
when '['
|
157
|
+
slices[label] = parse(input)
|
158
|
+
label = ""
|
159
|
+
when ']'
|
160
|
+
break
|
161
|
+
else
|
162
|
+
label << char
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
slices[label] = nil unless label.empty?
|
167
|
+
|
168
|
+
return Slice.new(slices)
|
169
|
+
end
|
170
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
require 'tree_filter'
|
4
|
+
|
5
|
+
describe 'Tree filter spec:' do
|
6
|
+
def filter(data, input)
|
7
|
+
TreeFilter.new(input).filter(data)
|
8
|
+
end
|
9
|
+
|
10
|
+
describe 'filtering a hash' do
|
11
|
+
it 'only includes specified attributes' do
|
12
|
+
data = {'a' => 1, 'b' => 2, 'c' => 3}
|
13
|
+
|
14
|
+
expect(filter(data, 'a,b')).to eq('a' => 1, 'b' => 2)
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'can include sub-tree attributes' do
|
18
|
+
data = {'a' => {'c' => 3, 'd' => 4}, 'b' => 2}
|
19
|
+
|
20
|
+
expect(filter(data, 'a[c]')).to eq('a' => {'c' => 3})
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'can traverse arbitrary depth' do
|
24
|
+
data = {'a' => {'b' => {'c' => 1, 'd' => 2}, 'e' => 3, 'f' => 4}}
|
25
|
+
|
26
|
+
expect(filter(data, 'a[b[c],e]')).to \
|
27
|
+
eq('a' => {'b' => {'c' => 1}, 'e' => 3})
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'includes nil values' do
|
31
|
+
data = {'a' => 1, 'b' => nil, 'c' => 3}
|
32
|
+
|
33
|
+
expect(filter(data, 'a,b')).to eq('a' => 1, 'b' => nil)
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'can defer evaluation of lambdas' do
|
37
|
+
data = {
|
38
|
+
'a' => TreeFilter::Defer.new(->{ 1 }),
|
39
|
+
'b' => TreeFilter::Defer.new(->{ raise })
|
40
|
+
}
|
41
|
+
|
42
|
+
expect(filter(data, 'a')).to eq('a' => 1)
|
43
|
+
end
|
44
|
+
|
45
|
+
it 'filters defered evaluations' do
|
46
|
+
data = {
|
47
|
+
'a' => TreeFilter::Defer.new(->{{'b' => 1, 'c' => 2}}),
|
48
|
+
}
|
49
|
+
|
50
|
+
expect(filter(data, 'a[b]')).to eq('a' => {'b' => 1})
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'allows cyclic references with defer' do
|
54
|
+
data = {
|
55
|
+
'a' => TreeFilter::Leaf.new(1, TreeFilter::Defer.new(->{ data }))
|
56
|
+
}
|
57
|
+
|
58
|
+
expect(filter(data, 'a[a[a]]').to_json).to \
|
59
|
+
eq({'a' => {'a' => {'a' => 1}}}.to_json)
|
60
|
+
end
|
61
|
+
|
62
|
+
it 'handles null filter' do
|
63
|
+
data = {'a' => {'c' => 3, 'd' => 4}, 'b' => 2}
|
64
|
+
|
65
|
+
expect(filter(data, 'a[]')).to eq('a' => {})
|
66
|
+
end
|
67
|
+
|
68
|
+
it 'allows leaf alternation' do
|
69
|
+
data = {'a' => TreeFilter::Leaf.new('/a', 'id' => 'a', 'name' => 'b')}
|
70
|
+
|
71
|
+
expect(filter(data, 'a')).to eq('a' => '/a')
|
72
|
+
expect(filter(data, 'a[id]')).to eq('a' => {'id' => 'a'})
|
73
|
+
end
|
74
|
+
|
75
|
+
it 'allows recursive leaf alternation' do
|
76
|
+
data = {'a' => TreeFilter::Leaf.new('',
|
77
|
+
'b' => TreeFilter::Leaf.new('/b', 'f')
|
78
|
+
)}
|
79
|
+
|
80
|
+
expect(filter(data, 'a[b]')).to eq('a' => {'b' => '/b'})
|
81
|
+
end
|
82
|
+
|
83
|
+
it 'allows * for all attributes' do
|
84
|
+
data = {'a' => {'c' => 3, 'd' => 4}, 'b' => 2}
|
85
|
+
|
86
|
+
expect(filter(data, 'a[*]')).to eq('a' => {'c' => 3, 'd' => 4})
|
87
|
+
end
|
88
|
+
|
89
|
+
it 'takes left tree for *' do
|
90
|
+
data = {'a' => {'c' => TreeFilter::Leaf.new('/c', 'fail')}, 'b' => 2}
|
91
|
+
|
92
|
+
expect(filter(data, 'a[*]')).to eq('a' => {'c' => '/c'})
|
93
|
+
end
|
94
|
+
|
95
|
+
it 'converts objects to JSON' do
|
96
|
+
a = Object.new
|
97
|
+
def a.as_json(*args)
|
98
|
+
{'a' => 1, 'b' => 2}
|
99
|
+
end
|
100
|
+
|
101
|
+
data = {'a' => a}
|
102
|
+
|
103
|
+
expect(filter(a, 'b')).to eq('b' => 2)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
describe 'filtering an array' do
|
108
|
+
it 'applies filter to each element' do
|
109
|
+
data = [{'a' => 1, 'b' => 2}, {'a' => 3, 'b' => 4}]
|
110
|
+
|
111
|
+
expect(filter(data, 'a')).to eq([{'a' => 1}, {'a' => 3}])
|
112
|
+
end
|
113
|
+
|
114
|
+
it 'allows leaf alternation' do
|
115
|
+
data = ['a' => TreeFilter::Leaf.new('/a', 'id' => 'a', 'name' => 'b')]
|
116
|
+
|
117
|
+
expect(filter(data, 'a[id]')).to eq(['a' => {'id' => 'a'}])
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
it 'does not try to recurse into plain values' do
|
122
|
+
data = {'a' => 'b'}
|
123
|
+
|
124
|
+
expect(filter(data, 'a[*]')).to eq('a' => 'b')
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
data/tree_filter.gemspec
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
Gem::Specification.new do |gem|
|
3
|
+
gem.authors = ["Xavier Shay"]
|
4
|
+
gem.email = ["xavier@squareup.com"]
|
5
|
+
gem.description =
|
6
|
+
%q{Filter arbitrary data trees with a concise query language.}
|
7
|
+
gem.summary = %q{
|
8
|
+
Filter arbitrary data trees (hashes, arrays, values) with a concise query
|
9
|
+
language. Handles cyclic structures.
|
10
|
+
}
|
11
|
+
gem.homepage = "http://github.com/square/ruby-tree_filter"
|
12
|
+
|
13
|
+
gem.executables = []
|
14
|
+
gem.required_ruby_version = '>= 1.9.0'
|
15
|
+
gem.files = Dir.glob("{spec,lib}/**/*.rb") + %w(
|
16
|
+
README.md
|
17
|
+
HISTORY.md
|
18
|
+
LICENSE
|
19
|
+
tree_filter.gemspec
|
20
|
+
)
|
21
|
+
gem.test_files = Dir.glob("spec/**/*.rb")
|
22
|
+
gem.name = "tree_filter"
|
23
|
+
gem.require_paths = ["lib"]
|
24
|
+
gem.license = "Apache 2.0"
|
25
|
+
gem.version = '1.0.0'
|
26
|
+
gem.has_rdoc = false
|
27
|
+
end
|
metadata
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: tree_filter
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Xavier Shay
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-03-19 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: Filter arbitrary data trees with a concise query language.
|
14
|
+
email:
|
15
|
+
- xavier@squareup.com
|
16
|
+
executables: []
|
17
|
+
extensions: []
|
18
|
+
extra_rdoc_files: []
|
19
|
+
files:
|
20
|
+
- HISTORY.md
|
21
|
+
- LICENSE
|
22
|
+
- README.md
|
23
|
+
- lib/tree_filter.rb
|
24
|
+
- spec/tree_filter_spec.rb
|
25
|
+
- tree_filter.gemspec
|
26
|
+
homepage: http://github.com/square/ruby-tree_filter
|
27
|
+
licenses:
|
28
|
+
- Apache 2.0
|
29
|
+
metadata: {}
|
30
|
+
post_install_message:
|
31
|
+
rdoc_options: []
|
32
|
+
require_paths:
|
33
|
+
- lib
|
34
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
35
|
+
requirements:
|
36
|
+
- - ">="
|
37
|
+
- !ruby/object:Gem::Version
|
38
|
+
version: 1.9.0
|
39
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '0'
|
44
|
+
requirements: []
|
45
|
+
rubyforge_project:
|
46
|
+
rubygems_version: 2.2.2
|
47
|
+
signing_key:
|
48
|
+
specification_version: 4
|
49
|
+
summary: Filter arbitrary data trees (hashes, arrays, values) with a concise query
|
50
|
+
language. Handles cyclic structures.
|
51
|
+
test_files:
|
52
|
+
- spec/tree_filter_spec.rb
|
53
|
+
has_rdoc: false
|