lutaml-path 0.1.0 → 0.1.1

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,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "condition"
4
+ require_relative "condition_evaluator"
5
+ require_relative "step"
6
+ require_relative "instance_path"
7
+
8
+ module Lutaml
9
+ module Path
10
+ # Resolves parsed instance paths against real model data:
11
+ # lutaml-model Serializable instances, plain hashes, and their
12
+ # collections. This is the evaluation half of RS 3002's instance
13
+ # data syntax — the parser deliberately stays parse-only
14
+ # (InstancePath#match? raises), so resolution lives here where the
15
+ # object-model knowledge (attributes, hashes, enumerables)
16
+ # belongs.
17
+ #
18
+ # Resolution walks InstancePath#steps: base steps anchor the root
19
+ # (treated as attribute navigation from the given root), attribute
20
+ # steps read named children, `**` selects every descendant, and a
21
+ # step's condition filters the step's candidate nodes.
22
+ module Resolver
23
+ module_function
24
+
25
+ def resolve(root, path)
26
+ path = normalized(path)
27
+ context = [root]
28
+ path.steps.each do |step|
29
+ context = apply_step(context, step)
30
+ end
31
+ finalize(context, path)
32
+ end
33
+
34
+ def apply_step(context, step)
35
+ context = if step.deep_wildcard?
36
+ descendants(context)
37
+ else
38
+ context.flat_map { |node| named_children(node, step) }
39
+ end
40
+ step.condition? ? filter(context, step.condition) : context
41
+ end
42
+
43
+ def filter(context, condition)
44
+ context.select { |node| ConditionEvaluator.evaluate(condition, node) }
45
+ end
46
+
47
+ # Reads a dotted AttributeRef ("role.type") from a context node.
48
+ # Condition paths are plain names (the grammar admits no glob
49
+ # characters in attribute references).
50
+ def read_path(object, attribute_ref)
51
+ context = [object]
52
+ attribute_ref.names.each do |name|
53
+ context = context.flat_map { |node| exact_children(node, name) }
54
+ end
55
+ return nil if context.empty?
56
+
57
+ context.first
58
+ end
59
+
60
+ def exact_children(node, name)
61
+ entries(node).filter_map do |child_name, value|
62
+ next if value.nil?
63
+ next unless child_name.to_s == name
64
+
65
+ value.is_a?(::Enumerable) && !value.is_a?(::Hash) && !value.is_a?(::String) ? value.to_a : value
66
+ end.flatten(1)
67
+ end
68
+
69
+ def named_children(node, step)
70
+ named_targets(node, step.segment)
71
+ end
72
+
73
+ # Children of `node` whose name matches the step's segment; a
74
+ # collection value contributes its elements.
75
+ def named_targets(node, segment)
76
+ entries(node).filter_map do |child_name, value|
77
+ next if value.nil?
78
+ next unless segment.match?(child_name.to_s)
79
+
80
+ value.is_a?(::Enumerable) && !value.is_a?(::Hash) && !value.is_a?(::String) ? value.to_a : value
81
+ end.flatten(1)
82
+ end
83
+
84
+ def descendants(context)
85
+ seen = {}.compare_by_identity
86
+ context.each do |node|
87
+ walk(node) { |value| seen[value] = true unless value.nil? }
88
+ end
89
+ seen.keys
90
+ end
91
+
92
+ def walk(node, &block)
93
+ entries(node).map(&:last).each do |value|
94
+ next if value.nil?
95
+
96
+ block.call(value)
97
+ walk(value, &block)
98
+ end
99
+ end
100
+
101
+ # (name, value) pairs addressable on `node`.
102
+ def entries(node)
103
+ return [] if primitive?(node)
104
+
105
+ if node.is_a?(::Hash)
106
+ node.map { |key, value| [key, value] }
107
+ elsif node.is_a?(::Enumerable)
108
+ node.each_with_index.map { |value, index| [index, value] }
109
+ elsif model_instance?(node)
110
+ model_entries(node)
111
+ else
112
+ []
113
+ end
114
+ end
115
+
116
+ def primitive?(node)
117
+ node.nil? || node.is_a?(::String) || node.is_a?(::Numeric) || node.is_a?(::Symbol)
118
+ end
119
+
120
+ def model_instance?(node)
121
+ defined?(Lutaml::Model) && node.is_a?(Lutaml::Model::Serializable)
122
+ end
123
+
124
+ def model_entries(node)
125
+ node.class.attributes.keys.filter_map do |name|
126
+ next unless node.respond_to?(name)
127
+
128
+ [name, node.public_send(name)]
129
+ end
130
+ end
131
+
132
+ # A bare name parses as a model path (the grammar's default);
133
+ # for resolution its segments are attribute steps.
134
+ def normalized(path)
135
+ return path if path.respond_to?(:steps)
136
+
137
+ steps = path.segments.map { |segment| Step.new(segment) }
138
+ InstancePath.new(base_steps: [], attribute_steps: steps, absolute: path.absolute?)
139
+ end
140
+
141
+ # Plain chains (no filters, patterns, or deep wildcards) resolve
142
+ # to a single value when exactly one non-collection node matched;
143
+ # everything else returns the collection.
144
+ def finalize(context, path)
145
+ plain = path.steps.none? do |step|
146
+ step.condition? || step.pattern? || step.deep_wildcard?
147
+ end
148
+ return context.first if plain && context.size == 1 && !context.first.is_a?(::Enumerable)
149
+
150
+ context
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Lutaml
6
+ module Path
7
+ # One segment of a path plus the filter condition attached to it.
8
+ #
9
+ # Step composes a PathSegment rather than extending it, and deliberately
10
+ # does NOT answer match?: a step can carry a condition, and matching by
11
+ # name alone would silently ignore that condition and report a match for
12
+ # a path that says something narrower. PathSegment stays filter-free and
13
+ # keeps match?; anything that can carry a filter never answers it.
14
+ #
15
+ # Composition is also required for correctness: "**[name='ISO']" as a
16
+ # single PathSegment name is not a deep wildcard, whereas name "**" plus a
17
+ # separate condition keeps deep_wildcard? true.
18
+ class Step
19
+ extend Forwardable
20
+
21
+ attr_reader :segment, :condition
22
+
23
+ def_delegators :segment, :name, :pattern?, :deep_wildcard?
24
+
25
+ def initialize(segment, condition: nil)
26
+ @segment = segment
27
+ @condition = condition
28
+ end
29
+
30
+ def condition?
31
+ !condition.nil?
32
+ end
33
+
34
+ def to_s
35
+ condition? ? "#{segment}[#{condition}]" : segment.to_s
36
+ end
37
+
38
+ def ==(other)
39
+ other.instance_of?(self.class) &&
40
+ segment == other.segment &&
41
+ condition == other.condition
42
+ end
43
+ alias eql? ==
44
+
45
+ def hash
46
+ [self.class, segment, condition].hash
47
+ end
48
+ end
49
+ end
50
+ end
@@ -6,36 +6,78 @@ require "parslet"
6
6
  module Lutaml
7
7
  module Path
8
8
  class Transformer < Parslet::Transform
9
- rule(content: simple(:content), is_pattern: simple(:is_pattern)) do |dict|
10
- content = dict[:content].to_s
11
- is_pattern = !dict[:is_pattern].nil?
12
- PathSegment.new(content, is_pattern: is_pattern)
9
+ # --- condition AST ----------------------------------------------------
10
+ # Tokenise into names rather than splitting on a naive lookbehind: a name
11
+ # may itself end in an escaped backslash, so "a\\\\.b" is the two names
12
+ # ["a\\", "b"] while "a\\.b" is the single name "a.b". Each token is a run
13
+ # of escaped pairs and ordinary characters; the dots between them separate.
14
+ rule(attref: simple(:a)) do
15
+ names = a.to_s.scan(/(?:\\.|[^.\\])+/)
16
+ Condition::AttributeRef.new(names.map { |n| n.gsub(/\\(.)/) { ::Regexp.last_match(1) } })
13
17
  end
18
+ # An empty quoted value ('') makes Parslet's repeat yield [] rather than a
19
+ # slice, so `simple` would never match and the node would stay raw.
20
+ rule(string: subtree(:s)) do
21
+ raw = s.is_a?(Array) ? "" : s.to_s
22
+ # One pass, so "\\\\'" unescapes to a backslash followed by a quote
23
+ # rather than being double-unescaped by two independent gsubs.
24
+ Condition::Value.new(raw.gsub(/\\(['\\])/) { ::Regexp.last_match(1) }, :string)
25
+ end
26
+ rule(number: simple(:n)) { Condition::Value.new(n.to_s, :number) }
27
+ rule(exists: simple(:_)) { Condition::Existence.new }
14
28
 
15
- rule(segment: subtree(:segment)) { segment }
29
+ rule(lhs: subtree(:l), op: simple(:o), rhs: subtree(:r)) do |d|
30
+ Condition::Comparison.new(d[:l], d[:o].to_s, d[:r])
31
+ end
32
+
33
+ # Parslet collapses a one-element repetition to a bare Hash rather than a
34
+ # one-element Array, and Array({v: x}) would yield [[:v, x]] via Hash#to_a
35
+ # -- so `in ('ISBN')` used to raise TypeError out of Lutaml::Path.parse.
36
+ # Wrap-then-flatten normalises both shapes.
37
+ rule(lhs: subtree(:l), literals: subtree(:v)) do |d|
38
+ Condition::Membership.new(d[:l], [d[:v]].flatten.map { |h| h[:v] })
39
+ end
16
40
 
17
- # Transform more_segments rule
18
- rule(more_segments: sequence(:segments)) { segments }
19
- rule(more_segments: simple(:segment)) { [segment].compact } # For single segment
20
- rule(more_segments: []) { [] } # For empty segments
41
+ rule(not: subtree(:n)) { Condition::Negation.new(n) }
21
42
 
22
- # Absolute path
23
- rule(
24
- absolute: simple(:_),
25
- first_segment: simple(:first),
26
- more_segments: sequence(:rest)
27
- ) do |dict|
28
- segments = [dict[:first]] + Array(dict[:rest]).compact
29
- ElementPath.new(segments, absolute: true)
43
+ rule(binary: { left: subtree(:l), operator: simple(:o), right: subtree(:r) }) do |d|
44
+ Condition::BinaryOperation.new(d[:o].to_s, d[:l], d[:r])
30
45
  end
31
46
 
32
- # Relative path
47
+ # --- steps ------------------------------------------------------------
48
+ # Every segment becomes a Step uniformly. The transform builds bottom-up
49
+ # and a leaf cannot know whether the whole path has dot-steps or filters,
50
+ # so the root rule -- the only place that knows -- decides the type and
51
+ # unwraps Step -> PathSegment when it picks ElementPath.
52
+ rule(content: simple(:content)) do |dict|
53
+ Step.new(PathSegment.new(dict[:content].to_s))
54
+ end
55
+
56
+ rule(content: simple(:content), condition: subtree(:cond)) do |dict|
57
+ Step.new(PathSegment.new(dict[:content].to_s), condition: dict[:cond])
58
+ end
59
+
60
+ rule(segment: subtree(:segment)) { segment }
61
+
62
+ # Single root rule. `abs` is nil for a relative path and a "::" slice for
63
+ # an absolute one. One rule means one key set, so adding a key later
64
+ # cannot silently un-match the root and hand back a raw Hash.
33
65
  rule(
66
+ absolute: simple(:abs),
34
67
  first_segment: simple(:first),
35
- more_segments: sequence(:rest)
68
+ more_segments: sequence(:rest),
69
+ attrs: sequence(:attrs)
36
70
  ) do |dict|
37
- segments = [dict[:first]] + Array(dict[:rest]).compact
38
- ElementPath.new(segments, absolute: false)
71
+ base = [dict[:first]] + Array(dict[:rest]).compact
72
+ attributes = Array(dict[:attrs]).compact
73
+ absolute = !dict[:abs].nil?
74
+
75
+ if attributes.empty? && base.none?(&:condition?)
76
+ ElementPath.new(base.map(&:segment), absolute: absolute)
77
+ else
78
+ InstancePath.new(base_steps: base, attribute_steps: attributes,
79
+ absolute: absolute)
80
+ end
39
81
  end
40
82
  end
41
83
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Path
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.1"
6
6
  end
7
7
  end
data/lib/lutaml/path.rb CHANGED
@@ -2,20 +2,51 @@
2
2
 
3
3
  require "parslet"
4
4
  require_relative "path/version"
5
+ require_relative "path/errors"
5
6
  require_relative "path/parser"
6
7
  require_relative "path/transformer"
7
- require_relative "path/element_path"
8
8
  require_relative "path/path_segment"
9
+ require_relative "path/condition"
10
+ require_relative "path/step"
11
+ # abstract_path must precede element_path: `class ElementPath < AbstractPath`
12
+ # resolves the constant at class-definition time.
13
+ require_relative "path/abstract_path"
14
+ require_relative "path/element_path"
15
+ require_relative "path/instance_path"
16
+ require_relative "path/resolver"
9
17
 
10
18
  module Lutaml
11
19
  module Path
12
- class ParseError < StandardError; end
13
-
14
20
  def self.parse(input)
15
21
  tree = Parser.new.parse(input)
16
22
  Transformer.new.apply(tree)
17
23
  rescue Parslet::ParseFailed => e
18
24
  raise ParseError, e.message
25
+ rescue SystemStackError
26
+ # The condition grammar recurses through group/not/and/or, so an input
27
+ # nested deeply enough (~200 parentheses) exhausts the stack. That is a
28
+ # property of the input, not a bug in the caller: parse must only ever
29
+ # raise ParseError.
30
+ raise ParseError, "expression nests too deeply to parse"
31
+ end
32
+
33
+ # Resolves an instance data path against model instance data: a
34
+ # lutaml-model Serializable instance, a hash, or collections
35
+ # thereof. The path may be a parsed InstancePath or an instance
36
+ # path string.
37
+ def self.resolve(root, path)
38
+ path = parse_instance(path) if path.is_a?(::String)
39
+ Resolver.resolve(root, path)
40
+ end
41
+
42
+ # Parses an input explicitly as an instance data path.
43
+ def self.parse_instance(input)
44
+ tree = Parser.new.parse(input)
45
+ Transformer.new.apply(tree)
46
+ rescue Parslet::ParseFailed => e
47
+ raise ParseError, e.message
48
+ rescue SystemStackError
49
+ raise ParseError, "expression nests too deeply to parse"
19
50
  end
20
51
  end
21
52
  end
data/lutaml-path.gemspec CHANGED
@@ -28,6 +28,7 @@ Gem::Specification.new do |spec|
28
28
  end
29
29
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
30
30
 
31
- spec.add_dependency "parslet"
31
+ spec.add_dependency "lutaml-model", ">= 0.8"
32
+ spec.add_dependency "parslet", "~> 2"
32
33
  spec.metadata["rubygems_mfa_required"] = "true"
33
34
  end