enumpath 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+ require "yard"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task :default => :spec
8
+
9
+ YARD::Rake::YardocTask.new do |t|
10
+ t.options = ['--no-private', '-', 'LICENSE']
11
+ end
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "enumpath"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/enumpath.gemspec ADDED
@@ -0,0 +1,32 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "enumpath/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "enumpath"
8
+ spec.version = Enumpath::VERSION
9
+ spec.authors = ["Chris Bloom"]
10
+ spec.email = ["chrisbloom7@gmail.com"]
11
+
12
+ spec.summary = "A JSONPath-compatible library for navigating Ruby objects using path expressions"
13
+ spec.homepage = "https://github.com/youearnedit/enumpath"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
16
+ f.match(%r{^(test|spec|features)/})
17
+ end
18
+ spec.require_paths = ["lib"]
19
+
20
+ # Enumerable#dig was added in Ruby 2.3.0
21
+ spec.required_ruby_version = '>= 2.3.0'
22
+
23
+ spec.add_dependency "to_regexp", "~> 0.2.1"
24
+ spec.add_dependency "mini_cache", "~> 1.1.0"
25
+
26
+ spec.add_development_dependency "bundler", "~> 1.16"
27
+ spec.add_development_dependency "null-logger", "~> 0.1"
28
+ spec.add_development_dependency "pry-byebug", "~> 3.6"
29
+ spec.add_development_dependency "rake", "~> 12.3"
30
+ spec.add_development_dependency "rspec-benchmark", "~> 0.3.0"
31
+ spec.add_development_dependency "rspec", "~> 3.8"
32
+ end
data/lib/enumpath.rb ADDED
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mini_cache'
4
+ require "enumpath/logger"
5
+ require "enumpath/operator"
6
+ require "enumpath/path"
7
+ require "enumpath/results"
8
+ require "enumpath/resolver/simple"
9
+ require "enumpath/resolver/property"
10
+ require "enumpath/version"
11
+
12
+ # A JSONPath-compatible library for navigating Ruby objects using path expressions
13
+ module Enumpath
14
+ @verbose = false
15
+
16
+ class << self
17
+ # Whether verbose mode is enabled. When enabled, the {Enumpath::Logger} will print
18
+ # information to the logging stream to assist in debugging path expressions.
19
+ # Defaults to false
20
+ #
21
+ # @return [true,false]
22
+ attr_accessor :verbose
23
+
24
+ # Resolve a path expression against an enumerable
25
+ #
26
+ # @param path (see Enumpath::Path#initialize)
27
+ # @param enum (see Enumpath::Path#apply)
28
+ # @param options [optional, Hash]
29
+ # @option options [Symbol] :result_type (:value) The type of results to return, `:value` or `:path`
30
+ # @option options [true, false] :verbose (false) Whether to enable additional output for debugging
31
+ # @return (see Enumpath::Path#apply)
32
+ def apply(path, enum, options = {})
33
+ logger.level = 0
34
+ @verbose = options.delete(:verbose) || false
35
+ Enumpath::Path.new(path, result_type: options.delete(:result_type)).apply(enum)
36
+ end
37
+
38
+ # The {Enumpath::Logger} instance to use with verbose mode
39
+ #
40
+ # @private
41
+ # @return [Enumpath::Logger]
42
+ def logger
43
+ @logger ||= Enumpath::Logger.new
44
+ end
45
+
46
+ # A shortcut to {Enumpath::logger.log}
47
+ #
48
+ # @private
49
+ # @see Enumpath::Logger#log
50
+ def log(title)
51
+ block_given? ? logger.log(title, &Proc.new) : logger.log(title)
52
+ end
53
+
54
+ # A lightweight in-memory cache for caching normalized path expressions
55
+ #
56
+ # @private
57
+ # @return [MiniCache::Store]
58
+ def path_cache
59
+ @path_cache ||= MiniCache::Store.new
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module Enumpath
6
+ # A logger for providing debugging information while evaluating path expressions
7
+ # @private
8
+ class Logger
9
+ PAD = " "
10
+
11
+ SEPARATOR = '--------------------------------------'
12
+
13
+ # @return [Integer] the indentation level to apply to log messages
14
+ attr_accessor :level
15
+
16
+ # @return [::Logger, #<<] a {::Logger}-compatible logger instance
17
+ attr_accessor :logger
18
+
19
+ # @param logdev [String, IO] The log device. See Ruby's {::Logger.new} documentation.
20
+ def initialize(logdev = STDOUT)
21
+ @logger = ::Logger.new(logdev)
22
+ @level = 0
23
+ @padding = {}
24
+ end
25
+
26
+ # Generates a log message for debugging. Returns fast if {Enumpath.verbose} is false. Accepts an optional block
27
+ # which must contain a single hash, the contents of which will be added to the log message, and which are lazily
28
+ # evaluated only if {Enumpath.verbose} is true.
29
+ #
30
+ # @param title [String] the title of this log message
31
+ # @yield A lazily evaluated hash of key/value pairs to include in the log message
32
+ def log(title)
33
+ return unless Enumpath.verbose
34
+ append_log "#{padding}#{SEPARATOR}\n"
35
+ append_log "#{padding}Enumpath: #{title}\n"
36
+ if block_given?
37
+ append_log "#{padding}#{SEPARATOR}\n"
38
+ vars = yield
39
+ return unless vars.is_a?(Hash)
40
+ label_size = vars.keys.map(&:size).max
41
+ vars.each do |label, value|
42
+ append_log "#{padding}#{label.to_s.ljust(label_size)}: #{massaged_value(value)}\n"
43
+ end
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def append_log(message)
50
+ logger << message
51
+ end
52
+
53
+ def massaged_value(value)
54
+ if value.is_a?(Enumerable)
55
+ enum_for_log(value)
56
+ elsif value.is_a?(TrueClass)
57
+ 'True'
58
+ elsif value.is_a?(FalseClass)
59
+ 'False'
60
+ elsif value.nil?
61
+ 'Nil'
62
+ else
63
+ value.to_s
64
+ end
65
+ end
66
+
67
+ def enum_for_log(enum, length = 50)
68
+ json = enum.inspect
69
+ "#{json[0...length]}#{json.length > length ? '...' : ''}"
70
+ end
71
+
72
+ def padding
73
+ PAD * level.to_i
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'enumpath/operator/base'
4
+ require 'enumpath/operator/child'
5
+ require 'enumpath/operator/filter_expression'
6
+ require 'enumpath/operator/recursive_descent'
7
+ require 'enumpath/operator/subscript_expression'
8
+ require 'enumpath/operator/slice'
9
+ require 'enumpath/operator/union'
10
+ require 'enumpath/operator/wildcard'
11
+
12
+ module Enumpath
13
+ # Namespace for classes that represent path expression operators
14
+ module Operator
15
+ ROOT = '$'
16
+
17
+ class << self
18
+ # Infer the type of operator and return an instance of its Enumpath::Operator subclass
19
+ #
20
+ # @param operator [String] the operator to infer type on
21
+ # @param enum [Enumerable] the enumerable to assist in detecting child operators
22
+ # @return an instance of a subclass of Enumpath::Operator based on what was detected, or nil if nothing was
23
+ # detected
24
+ def detect(operator, enum)
25
+ if Enumpath::Operator::Child.detect?(operator, enum)
26
+ Enumpath.log('Child operator detected')
27
+ Enumpath::Operator::Child.new(operator)
28
+ elsif Enumpath::Operator::Wildcard.detect?(operator)
29
+ Enumpath.log('Wildcard operator detected')
30
+ Enumpath::Operator::Wildcard.new(operator)
31
+ elsif Enumpath::Operator::RecursiveDescent.detect?(operator)
32
+ Enumpath.log('Recursive Descent operator detected')
33
+ Enumpath::Operator::RecursiveDescent.new(operator)
34
+ elsif Enumpath::Operator::Union.detect?(operator)
35
+ Enumpath.log('Union operator detected')
36
+ Enumpath::Operator::Union.new(operator)
37
+ elsif Enumpath::Operator::SubscriptExpression.detect?(operator)
38
+ Enumpath.log('Subscript Expression operator detected')
39
+ Enumpath::Operator::SubscriptExpression.new(operator)
40
+ elsif Enumpath::Operator::FilterExpression.detect?(operator)
41
+ Enumpath.log('Filter Expression operator detected')
42
+ Enumpath::Operator::FilterExpression.new(operator)
43
+ elsif Enumpath::Operator::Slice.detect?(operator)
44
+ Enumpath.log('Slice operator detected')
45
+ Enumpath::Operator::Slice.new(operator)
46
+ else
47
+ Enumpath.log('Not a valid operator for enum')
48
+ nil
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumpath
4
+ module Operator
5
+ # Abstract base class for Operator definitions. Provides some helper methods for each operator and defines the
6
+ # basic factory methods that must be implemented.
7
+ #
8
+ # @abstract Subclass and override {.detect?} and {#apply} to implement a new path expression operator.
9
+ class Base
10
+ class << self
11
+ # Provides an interface for determining if a given string represents the operator class
12
+ #
13
+ # @abstract Override in each path expression operator subclass
14
+ #
15
+ # @param operator [String] the the full, normalized operator to test
16
+ # @param enum [Enumerable] an enum that can be used to assist in detection. Not all subclasses require an enum
17
+ # for detection.
18
+ # @return [true, false] whether the operator param appears to represent the operator class
19
+ def detect?(operator, enum = nil)
20
+ raise NotImplementedError
21
+ end
22
+ end
23
+
24
+ # @return [String] the full, normalized operator
25
+ attr_reader :operator
26
+
27
+ # Initializes an operator class with an operator string
28
+ #
29
+ # @param operator [String] the the full, normalized operator
30
+ def initialize(operator)
31
+ @operator = operator
32
+ end
33
+
34
+ # Provides an interface for applying the operator to a given enumerable and yielding that result back to the
35
+ # caller with updated arguments
36
+ #
37
+ # @abstract Override in each path expression operator subclass
38
+ #
39
+ # @param remaining_path [Array] an array containing the normalized path segments yet to be resolved
40
+ # @param enum [Enumerable] the object to apply the operator to
41
+ # @param resolved_path [Array] an array containing the static path segments that have been resolved
42
+ #
43
+ # @yield A block that will be called if the operator is applied successfully. If the operator cannot or should
44
+ # not be applied then the block is not yielded.
45
+ #
46
+ # @yieldparam remaining_path [Array] the new remaining_path after applying the operator
47
+ # @yieldparam enum [Enumerable] the new enum after applying the operator
48
+ # @yieldparam resolved_path [Array] the new resolved_path after applying the operator
49
+ # @yieldreturn [void]
50
+ def apply(remaining_path, enum, resolved_path, &block)
51
+ raise NotImplementedError
52
+ end
53
+
54
+ # An alias to {#operator}, used by {Enumpath::Logger}
55
+ #
56
+ # @private
57
+ # @return [String] the operator the class was initialized with
58
+ def to_s
59
+ operator
60
+ end
61
+
62
+ private
63
+
64
+ # Returns a set of keys, member names, or indices for a given enumerable. Useful for operators that need to
65
+ # iterate over each member of an enumerable. If the object is an array then it will return an array of indexes.
66
+ # If the object can be converted to a hash (Hash, Struct, or anything responding to {#to_h}) then it will be
67
+ # forced to a hash and the hash keys will be returned. For all other objects, an empty array will be returned.
68
+ #
69
+ # @param enum [Enumerable] the object to detect keys, member names, or indices on.
70
+ # @return [Array] a set of keys, member names, or indices for the object, or an empty set if they could not be
71
+ # determined.
72
+ def keys(enum)
73
+ # Arrays
74
+ return (0...enum.length).to_a if enum.is_a?(Array)
75
+
76
+ # Other Enumerables
77
+ return enum.to_h.keys if enum.respond_to?(:to_h)
78
+
79
+ # Fallback
80
+ []
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumpath
4
+ module Operator
5
+ # Implements JSONPath child operator syntax. See {file:README.md#label-Child+operator} for syntax and examples.
6
+ class Child < Base
7
+ class << self
8
+ # Checks to see if an operator is valid as a child operator. It is considered valid if the enumerable contains
9
+ # an index, key, member, or property that responds to child.
10
+ #
11
+ # @param operator (see Enumpath::Operator::Base.detect?)
12
+ # @return (see Enumpath::Operator::Base.detect?)
13
+ def detect?(operator, enum)
14
+ !Enumpath::Resolver::Simple.resolve(operator, enum).nil? ||
15
+ !Enumpath::Resolver::Property.resolve(operator, enum).nil?
16
+ end
17
+ end
18
+
19
+ # Resolves a child operator against an enumerable. If the child operator matches a index, key, member, or
20
+ # property of the enumerable it is yielded to the block.
21
+ #
22
+ # @param (see Enumpath::Operator::Base#apply)
23
+ # @yield (see Enumpath::Operator::Base#apply)
24
+ # @yieldparam remaining_path [Array] remaining_path
25
+ # @yieldparam enum [Enumerable] the resolved value of the enumerable
26
+ # @yieldparam resolved_path [Array] resolved_path plus the child operator
27
+ def apply(remaining_path, enum, resolved_path, &block)
28
+ value = Enumpath::Resolver::Simple.resolve(operator, enum)
29
+ value = Enumpath::Resolver::Property.resolve(operator, enum) if value.nil?
30
+ yield(remaining_path, value, resolved_path + [operator]) if !value.nil?
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'to_regexp'
4
+
5
+ module Enumpath
6
+ module Operator
7
+ # Implements JSONPath filter expression operator syntax. See {file:README.md#label-Filter+expression+operator} for
8
+ # syntax and examples
9
+ class FilterExpression < Base
10
+ COMPARISON_OPERATOR_REGEX = /(==|!=|>=|<=|<=>|>|<|=~|!~)/
11
+ LOGICAL_OPERATORS_REGEX = /(&&)|(\|\|)/
12
+ OPERATOR_REGEX = /^\?\((.*)\)$/
13
+
14
+ class << self
15
+ # Whether the operator matches {Enumpath::Operator::FilterExpression::OPERATOR_REGEX}
16
+ #
17
+ # @param operator (see Enumpath::Operator::Base.detect?)
18
+ # @return (see Enumpath::Operator::Base.detect?)
19
+ def detect?(operator)
20
+ !!(operator =~ OPERATOR_REGEX)
21
+ end
22
+ end
23
+
24
+ # Yields to the block once for each member of the enumerable that passes the filter expression
25
+ #
26
+ # @param (see Enumpath::Operator::Base#apply)
27
+ # @yield (see Enumpath::Operator::Base#apply)
28
+ # @yieldparam remaining_path [Array] remaining_path
29
+ # @yieldparam enum [Enumerable] the member of the enumerable that passed the filter
30
+ # @yieldparam resolved_path [Array] resolved_path plus the key for each member of the enumerable that passed
31
+ # the filter
32
+ def apply(remaining_path, enum, resolved_path, &block)
33
+ Enumpath.log('Evaluating filter expression') { { expression: operator, to: enum } }
34
+
35
+ _match, unpacked_operator = OPERATOR_REGEX.match(operator).to_a
36
+ expressions = unpacked_operator.split(LOGICAL_OPERATORS_REGEX).map(&:strip)
37
+
38
+ keys(enum).each do |key|
39
+ value = Enumpath::Resolver::Simple.resolve(key, enum)
40
+ Enumpath.log('Applying filter to key') { { key: key, enum: value } }
41
+ if pass?(expressions.dup, value)
42
+ Enumpath.log('Applying filtered key') { { 'filtered key': key, 'filtered enum': value } }
43
+ yield(remaining_path, value, resolved_path + [key.to_s])
44
+ end
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def pass?(expressions, enum)
51
+ running_result = evaluate(expressions.shift, enum)
52
+ Enumpath.log('Initial result') { { result: running_result } }
53
+ while expressions.any? do
54
+ logical_operator, expression = expressions.shift(2)
55
+ running_result = evaluate(expression, enum, logical_operator, running_result)
56
+ Enumpath.log('Running result') { { result: running_result } }
57
+ end
58
+ running_result
59
+ end
60
+
61
+ def evaluate(expression, enum, logical_operator = nil, running_result = nil)
62
+ property, operator, operand = expression.split(COMPARISON_OPERATOR_REGEX).map(&:strip)
63
+ value = resolve(property, enum)
64
+ expression_result = test(operator, operand, value)
65
+ Enumpath.log('Evaluated filter') do
66
+ { property => value, operator: operator, operand: operand, result: expression_result,
67
+ logical_operator: logical_operator }.compact
68
+ end
69
+ if logical_operator == '&&'
70
+ Enumpath.log('&&=')
71
+ running_result &&= expression_result
72
+ elsif logical_operator == '||'
73
+ Enumpath.log('||=')
74
+ running_result ||= expression_result
75
+ else
76
+ expression_result
77
+ end
78
+ end
79
+
80
+ def resolve(property, enum)
81
+ return enum if property == '@'
82
+ value = Enumpath::Resolver::Simple.resolve(property.gsub(/^@\./, ''), enum)
83
+ value = Enumpath::Resolver::Property.resolve(property.gsub(/^@\./, ''), enum) if value.nil?
84
+ value
85
+ end
86
+
87
+ def test(operator, operand, value)
88
+ return !!value if operator.nil? || operand.nil?
89
+ typecast_operand = variable_typecaster(operand)
90
+ !!value.public_send(operator.to_sym, typecast_operand)
91
+ rescue NoMethodError
92
+ Enumpath.log('Filter could not be evaluated!')
93
+ false
94
+ end
95
+
96
+ def variable_typecaster(variable)
97
+ if variable =~ /\A('|").+\1\z/
98
+ # It quacks like a string
99
+ variable.gsub(/\A('|")|('|")\z/, '')
100
+ elsif variable =~ /^:.+/
101
+ # It quacks like a symbol
102
+ variable.gsub(/\A:/, '').to_sym
103
+ elsif variable =~ /true|false|nil/i
104
+ # It quacks like an unquoted boolean operator
105
+ variable == 'true' ? true : false
106
+ elsif regexp = variable.to_regexp(literal: false, detect: false)
107
+ # It quacks like a regex
108
+ regexp
109
+ else
110
+ # Otherwise treat it as a number
111
+ variable.to_f
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end