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.
- checksums.yaml +4 -4
- data/.github/workflows/rake.yml +15 -0
- data/.github/workflows/release.yml +30 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +7 -0
- data/README.adoc +651 -36
- data/lib/lutaml/path/abstract_path.rb +24 -0
- data/lib/lutaml/path/condition.rb +100 -0
- data/lib/lutaml/path/condition_evaluator.rb +109 -0
- data/lib/lutaml/path/element_path.rb +66 -8
- data/lib/lutaml/path/errors.rb +12 -0
- data/lib/lutaml/path/instance_path.rb +63 -0
- data/lib/lutaml/path/parser.rb +164 -13
- data/lib/lutaml/path/path_segment.rb +31 -5
- data/lib/lutaml/path/resolver.rb +154 -0
- data/lib/lutaml/path/step.rb +50 -0
- data/lib/lutaml/path/transformer.rb +63 -21
- data/lib/lutaml/path/version.rb +1 -1
- data/lib/lutaml/path.rb +34 -3
- data/lutaml-path.gemspec +2 -1
- data/spec/lutaml/path_spec.rb +674 -29
- data/spec/lutaml/resolver_spec.rb +182 -0
- metadata +29 -6
- data/.github/workflows/main.yml +0 -27
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
38
|
-
|
|
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
|
data/lib/lutaml/path/version.rb
CHANGED
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 "
|
|
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
|