enumpath 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumpath
4
+ module Operator
5
+ # Implements JSONPath recursive descent operator syntax. See {file:README.md#label-Recursive+descent+operator} for
6
+ # syntax and examples
7
+ class RecursiveDescent < Base
8
+ OPERATOR = '..'
9
+
10
+ class << self
11
+ # Simple test of whether the operator matches the {Enumpath::Operator::RecursiveDescent::OPERATOR} constant
12
+ #
13
+ # @param operator (see Enumpath::Operator::Base.detect?)
14
+ # @return (see Enumpath::Operator::Base.detect?)
15
+ def detect?(operator)
16
+ !!(operator == OPERATOR)
17
+ end
18
+ end
19
+
20
+ # Yields to the block once for the enumerable itself, and once for every direct member of the enumerable that is
21
+ # also an enumerable
22
+ #
23
+ # @param (see Enumpath::Operator::Base#apply)
24
+ # @yield (see Enumpath::Operator::Base#apply)
25
+ # @yieldparam remaining_path [Array] remaining_path for the enumerable itself, or the recursive descent
26
+ # operator plus remaining_path for each direct enumerable member
27
+ # @yieldparam enum [Enumerable] enum for the enumerable itself, or the direct enumerable member for each direct
28
+ # enumerable member
29
+ # @yieldparam resolved_path [Array] resolved_path for the enumerable itself, or resolved_path plus the key for
30
+ # each direct enumerable member
31
+ def apply(remaining_path, enum, resolved_path, &block)
32
+ Enumpath.log('Applying remaining path recursively to enum') { { 'remaining path': remaining_path } }
33
+ yield(remaining_path, enum, resolved_path)
34
+ keys(enum).each do |key|
35
+ value = Enumpath::Resolver::Simple.resolve(key, enum)
36
+ if recursable?(value)
37
+ Enumpath.log('Applying remaining path recursively to key') do
38
+ { key: key, 'remaining path': ['..'] + remaining_path }
39
+ end
40
+ yield(['..'] + remaining_path, value, resolved_path + [key])
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def recursable?(value)
48
+ value.is_a?(Enumerable)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumpath
4
+ module Operator
5
+ # Implements JSONPath array slice operator syntax. See {file:README.md#label-Slice+operator} for syntax and examples
6
+ class Slice < Base
7
+ OPERATOR_REGEX = /^(-?[0-9]*):(-?[0-9]*):?(-?[0-9]*)$/
8
+
9
+ class << self
10
+ # Whether the operator matches {Enumpath::Operator::Slice::OPERATOR_REGEX}
11
+ #
12
+ # @param operator (see Enumpath::Operator::Base.detect?)
13
+ # @return (see Enumpath::Operator::Base.detect?)
14
+ def detect?(operator)
15
+ !!(operator =~ OPERATOR_REGEX)
16
+ end
17
+ end
18
+
19
+ # Yields to the block once for each member of the local enumerable whose index is included by position between
20
+ # _start_ and up to (but not including) _end_. If _step_ is included then only every _step_ member is included,
21
+ # starting with the first.
22
+ #
23
+ # @param (see Enumpath::Operator::Base#apply)
24
+ # @yield (see Enumpath::Operator::Base#apply)
25
+ # @yieldparam remaining_path [Array] the included index plus remaining_path
26
+ # @yieldparam enum [Enumerable] enum
27
+ # @yieldparam resolved_path [Array] resolved_path
28
+ def apply(remaining_path, enum, resolved_path, &block)
29
+ _match, start, length, step = OPERATOR_REGEX.match(operator).to_a
30
+ max_length = enum.size
31
+ slices(start, length, step, max_length).each do |index|
32
+ Enumpath.log('Applying slice') { { slice: index } }
33
+ yield([index.to_s] + remaining_path, enum, resolved_path)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def slices(start, length, step, max_length)
40
+ start = slice_start(start, max_length)
41
+ length = slice_length(length, max_length)
42
+ step = slice_step(step)
43
+ (start...length).step(step)
44
+ end
45
+
46
+ def slice_start(start, max_length)
47
+ start = start.empty? ? 0 : start.to_i
48
+ start.negative? ? [0, start + max_length].max : [max_length, start].min
49
+ end
50
+
51
+ def slice_length(length, max_length)
52
+ length = length.empty? ? max_length : length.to_i
53
+ length.negative? ? [0, length + max_length].max : [max_length, length].min
54
+ end
55
+
56
+ def slice_step(step)
57
+ step.empty? ? 1 : step.to_i
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumpath
4
+ module Operator
5
+ # Implements JSONPath subscript expressions operator syntax. See
6
+ # {file:README.md#label-Subscript+expressions+operator} for syntax and examples
7
+ class SubscriptExpression < Base
8
+ ARITHMETIC_OPERATOR_REGEX = /(\+|-|\*\*|\*|\/|%)/
9
+ OPERATOR_REGEX = /^\((.*)\)$/
10
+
11
+ class << self
12
+ # Whether the operator matches {Enumpath::Operator::SubscriptExpression::OPERATOR_REGEX}
13
+ #
14
+ # @param operator (see Enumpath::Operator::Base.detect?)
15
+ # @return (see Enumpath::Operator::Base.detect?)
16
+ def detect?(operator)
17
+ !!(operator =~ OPERATOR_REGEX)
18
+ end
19
+ end
20
+
21
+ # Yields to the block once if the subscript expression evaluates to a member of the enumerable
22
+ #
23
+ # @param (see Enumpath::Operator::Base#apply)
24
+ # @yield (see Enumpath::Operator::Base#apply)
25
+ # @yieldparam remaining_path [Array] remaining_path
26
+ # @yieldparam enum [Enumerable] the member of the enumerable at the value of the subscript expression
27
+ # @yieldparam resolved_path [Array] resolved_path plus the value of the subscript expression
28
+ def apply(remaining_path, enum, resolved_path, &block)
29
+ Enumpath.log('Applying subscript expression') { { expression: operator, to: enum } }
30
+
31
+ _match, unpacked_operator = OPERATOR_REGEX.match(operator).to_a
32
+ result = evaluate(unpacked_operator, enum)
33
+
34
+ value = Enumpath::Resolver::Simple.resolve(result, enum)
35
+ if !value.nil?
36
+ Enumpath.log('Applying subscript') { { 'enum at subscript': value } }
37
+ yield(remaining_path, value, resolved_path + [result.to_s])
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def evaluate(unpacked_operator, enum)
44
+ property, operator, operand = unpacked_operator.split(ARITHMETIC_OPERATOR_REGEX).map(&:strip)
45
+ test(operator, operand, resolve(property, enum))
46
+ end
47
+
48
+ def resolve(property, enum)
49
+ return enum if property == '@'
50
+ value = Enumpath::Resolver::Simple.resolve(property.gsub(/^@\./, ''), enum)
51
+ value = Enumpath::Resolver::Property.resolve(property.gsub(/^@\./, ''), enum) if value.nil?
52
+ value
53
+ end
54
+
55
+ def test(operator, operand, value)
56
+ if operator.nil? || operand.nil?
57
+ Enumpath.log('Simple subscript') { { subscript: value } }
58
+ value
59
+ else
60
+ # Evaluate expression using operator
61
+ typecast_operand = variable_typecaster(operand)
62
+ result = value.public_send(operator.to_sym, typecast_operand)
63
+ Enumpath.log('Evaluated subscript') do
64
+ { value: value, operator: operator, operand: typecast_operand, result: result }
65
+ end
66
+ result
67
+ end
68
+ rescue NoMethodError
69
+ Enumpath.log('Subscript could not be evaluated') { { subscript: nil } }
70
+ nil
71
+ end
72
+
73
+ def variable_typecaster(variable)
74
+ if variable =~ /\A('|").+\1\z/ || variable =~ /^:.+/
75
+ # It quacks like a string or symbol
76
+ variable.gsub(/\A(:|('|"))|('|")\z/, '')
77
+ else
78
+ # Otherwise treat it as a number. Note that we only care about whole numbers in this case
79
+ variable.to_i
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TODO: Investigate supporting anchored paths (`$.foo.bar...`, `@.foo.bar...`)
4
+
5
+ module Enumpath
6
+ module Operator
7
+ # Implements JSONPath union operator syntax. See {file:README.md#label-Union+operator} for syntax and examples
8
+ class Union < Base
9
+ OPERATOR = /,/
10
+ CONSTRAINTS = /^,|,$/
11
+ SPLIT_REGEX = /,/
12
+
13
+ # The operator consists of
14
+ # a set of names or indices. Any member of the local enumerable that can
15
+ # be found at any of the keys or indices is yielded to the block.
16
+
17
+ class << self
18
+ # Whether the operator matches {Enumpath::Operator::Union::OPERATOR} and does not match
19
+ # {Enumpath::Operator::Union::CONSTRAINTS}
20
+ #
21
+ # @param operator (see Enumpath::Operator::Base.detect?)
22
+ # @return (see Enumpath::Operator::Base.detect?)
23
+ def detect?(operator)
24
+ operator.scan(',').any? && operator.scan(CONSTRAINTS).none?
25
+ end
26
+ end
27
+
28
+ # Yields to the block once for every union member
29
+ #
30
+ # @param (see Enumpath::Operator::Base#apply)
31
+ # @yield (see Enumpath::Operator::Base#apply)
32
+ # @yieldparam remaining_path [Array] the union member plus remaining_path
33
+ # @yieldparam enum [Enumerable] enum
34
+ # @yieldparam resolved_path [Array] resolved_path
35
+ def apply(remaining_path, enum, resolved_path, &block)
36
+ parts = operator.split(SPLIT_REGEX).map { |part| part.strip.gsub(/^['"]|['"]$/, '') }
37
+ Enumpath.log('Applying union parts') { { parts: parts } }
38
+ parts.each { |part| yield([part] + remaining_path, enum, resolved_path) }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumpath
4
+ module Operator
5
+ # Implements JSONPath wildcard operator syntax. See {file:README.md#label-Wildcard+operator} for syntax and examples
6
+ class Wildcard < Base
7
+ OPERATOR = '*'
8
+
9
+ class << self
10
+ # Simple test of whether the operator matches the {Enumpath::Operator::Wildcard::OPERATOR} constant
11
+ #
12
+ # @param operator (see Enumpath::Operator::Base.detect?)
13
+ # @return (see Enumpath::Operator::Base.detect?)
14
+ def detect?(operator)
15
+ operator == OPERATOR
16
+ end
17
+ end
18
+
19
+ # Yields to the block once for every direct member of the enumerable
20
+ #
21
+ # @param (see Enumpath::Operator::Base#apply)
22
+ # @yield (see Enumpath::Operator::Base#apply)
23
+ # @yieldparam remaining_path [Array] the key of the given member plus remaining_path
24
+ # @yieldparam enum [Enumerable] enum
25
+ # @yieldparam resolved_path [Array] resolved_path
26
+ def apply(remaining_path, enum, resolved_path, &block)
27
+ keys = keys(enum)
28
+ Enumpath.log('Applying wildcard to keys') { { keys: keys } }
29
+ keys.each { |key| yield([key.to_s] + remaining_path, enum, resolved_path) }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'enumpath/path/normalized_path'
4
+
5
+ module Enumpath
6
+ # A mechanism for applying path expressions to enumerables and tracking results
7
+ class Path
8
+ # @return [Enumpath::Path::NormalizedPath] the normalized path
9
+ attr_reader :path
10
+
11
+ # @return [Enumpath::Results] the current results array
12
+ attr_reader :results
13
+
14
+ # @param path [String, Array<String>] the path expression to apply to the enumerable
15
+ # @param result_type (see Enumpath::Results#initialize)
16
+ def initialize(path, result_type: nil)
17
+ @path = path
18
+ normalize!
19
+ @results = Enumpath::Results.new(result_type: result_type)
20
+ end
21
+
22
+ # Apply the path expression against an enumerable
23
+ #
24
+ # @note Calling this method resets the previous results array
25
+ #
26
+ # @param enum [Enumerable] the enumerable to apply the path to
27
+ # @return [Enumpath::Results] an array of resolved values or paths
28
+ def apply(enum)
29
+ results.clear
30
+ trace(@path.dup, enum)
31
+ results
32
+ end
33
+
34
+ private
35
+
36
+ # Applies the next normalized path segment to enumerable and keeps track of resolved path segments. This method
37
+ # recursively yields to itself via each Operator subclass's {#apply} method. If there are no remaining path
38
+ # segments then it stores a new result in the results array effectively ending processing on that branch of the
39
+ # original enumerator.
40
+ #
41
+ # @param path_segments [Array] an array containing the normalized path segments to be resolved
42
+ # @param enum [Enumerable] the object to apply the next normalized path segment to
43
+ # @param resolved_path [Array] an array containing the static path segments that have been resolved so far
44
+ # @param nesting_level [Integer] used to set the indentation level for {Enumpath::Logger}
45
+ # @return [void]
46
+ def trace(path_segments, enum, resolved_path = [], nesting_level = 0)
47
+ Enumpath.logger.level = nesting_level
48
+ if path_segments.any?
49
+ Enumpath.log("Applying") { { operator: path_segments, to: enum } }
50
+ segment = path_segments.first
51
+ remaining_path = path_segments[1..-1]
52
+ operator = Enumpath::Operator.detect(segment, enum)
53
+ operator&.apply(remaining_path, enum, resolved_path) do |s, e, c|
54
+ trace(s, e, c, nesting_level + 1)
55
+ end
56
+ else
57
+ Enumpath.log('Storing') { { resolved_path: resolved_path, enum: enum } }
58
+ results.store(resolved_path, enum)
59
+ end
60
+ Enumpath.logger.level = nesting_level
61
+ end
62
+
63
+ private
64
+
65
+ def cache
66
+ @cache ||= Enumpath.path_cache
67
+ end
68
+
69
+ def normalize!
70
+ @path = cache.get_or_set(@path) { Enumpath::Path::NormalizedPath.new(@path) }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumpath
4
+ class Path
5
+ # A utility for automatically normalizing string path expressions
6
+ class NormalizedPath < Array
7
+ FILTER_EXPRESSION_REGEX = /[\['](\??\(.*?\))[\]']/
8
+ INDEX_NOTATION_REGEX = /#([0-9]+)/
9
+
10
+ # @param path (see Enumpath::Path#initialize)
11
+ def initialize(path)
12
+ super(normalize(path))
13
+ end
14
+
15
+ private
16
+
17
+ def normalize(path)
18
+ return path if path.is_a?(Array)
19
+ normalized_path = path.dup
20
+ normalized_path, filter_expressions = remove_filter_expressions(normalized_path)
21
+ normalized_path.gsub!(/'?\.'?|\['?/, ';') # Replace "'?.'?" or "['?" with ";"
22
+ normalized_path.gsub!(/;;;|;;/, ';..;') # Replace ";;;" or ";;" with ";..;"
23
+ normalized_path.gsub!(/;\z|'?\]|'\z/, '') # Replace ";$" or "'?]" or "'$" with ""
24
+ normalized_path = restore_filter_expressions(normalized_path, filter_expressions)
25
+ normalized_path.gsub!(/\A\$(;|\z)/, '') # Remove root operator
26
+ normalized_path = normalized_path.split(';') # Split into segment parts
27
+ normalized_path.reject! do |segment| # Get rid of any blank segments
28
+ segment.nil? || segment.size == 0
29
+ end
30
+ Enumpath.log('Path normalized') { { original: path, normalized: normalized_path } }
31
+ normalized_path
32
+ end
33
+
34
+ # Move filter expressions (`[?(expr)]`) to the temporary array and replace with an index notation
35
+ def remove_filter_expressions(path)
36
+ filter_expressions = []
37
+ stripped_path = path.gsub(FILTER_EXPRESSION_REGEX) do
38
+ filter_expressions << $1
39
+ "[##{filter_expressions.size - 1}]"
40
+ end
41
+ [stripped_path, filter_expressions]
42
+ end
43
+
44
+ # Replace index notations with their corresponding filter expressions (`?(expr)`) from the temporary array
45
+ def restore_filter_expressions(path_expression, filter_expressions)
46
+ path_expression.gsub(INDEX_NOTATION_REGEX) { filter_expressions[$1.to_i] }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumpath
4
+ module Resolver
5
+ # A utility for resolving a string as a property of an object
6
+ class Property
7
+ class << self
8
+ # Attempts to resolve a string as a property of an object. In this context a property is a public method that
9
+ # expects no arguments.
10
+ #
11
+ # @param property [String] the name of the property to attempt to resolve
12
+ # @param object [Object] the object to resolve the property against
13
+ # @return the resolved property value, or nil if it could not be resolved
14
+ def resolve(property, object)
15
+ # TODO: return if Enumpath.disable_property_resolver
16
+ object.public_send(property.to_s.to_sym)
17
+ rescue ArgumentError, NoMethodError
18
+ nil
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Enumpath
4
+ module Resolver
5
+ # A utility for resolving a string as an index, key, or member of an enumerable
6
+ class Simple
7
+ NUMERIC_INDEX_TEST = /\A\-?(?:0|[1-9][0-9]*)\z/
8
+
9
+ class << self
10
+ # Attempts to resolve a string as an index, key, or member of an enumerable
11
+ #
12
+ # @param variable [String] the value to attempt to resolve
13
+ # @param enum [Enumerable] the enumerable to resolve the value against
14
+ # @return the resolved value, or nil if it could not be resolved
15
+ def resolve(variable, enum)
16
+ variable = variable.to_s
17
+ value = rescued_dig(enum, variable.to_i) if variable =~ NUMERIC_INDEX_TEST
18
+ value = rescued_dig(enum, variable) if value.nil?
19
+ value = rescued_dig(enum, variable.to_sym) if value.nil?
20
+ value
21
+ end
22
+
23
+ private
24
+
25
+ def rescued_dig(enum, typecast_variable)
26
+ enum.dig(typecast_variable)
27
+ rescue NoMethodError, TypeError
28
+ nil
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end