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