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 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