tree_filter 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|