tree_filter 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,5 @@
1
+ # Tree Filter History
2
+
3
+ ## 1.0.0 - 18 March 2015
4
+
5
+ * Initial release!
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
@@ -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
+
@@ -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