enumpath 0.1.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.
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