expressir 2.4.37-arm-linux → 2.4.38-arm-linux
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/TODO.instance/00-overview.md +73 -0
- data/TODO.instance/01-instance-typing.md +81 -0
- data/TODO.instance/02-population-rules.md +82 -0
- data/TODO.instance/03-p21-ladder-and-population-management.md +97 -0
- data/TODO.instance/04-arm-mim-instance-mapping.md +83 -0
- data/lib/expressir/cli.rb +3 -0
- data/lib/expressir/commands/population.rb +63 -0
- data/lib/expressir/commands.rb +1 -0
- data/lib/expressir/eval/population.rb +186 -0
- data/lib/expressir/eval/rules.rb +0 -1
- data/lib/expressir/eval.rb +1 -0
- data/lib/expressir/mapping/instance_verification.rb +405 -0
- data/lib/expressir/mapping.rb +1 -0
- data/lib/expressir/p21/builder.rb +78 -2
- data/lib/expressir/p21/dataset_manifest.rb +45 -0
- data/lib/expressir/p21/diff.rb +40 -0
- data/lib/expressir/p21/grammar.rb +48 -6
- data/lib/expressir/p21/json.rb +128 -0
- data/lib/expressir/p21/population.rb +6 -2
- data/lib/expressir/p21/receipt.rb +45 -0
- data/lib/expressir/p21/typing.rb +116 -0
- data/lib/expressir/p21/validator.rb +419 -8
- data/lib/expressir/p21/writer.rb +107 -0
- data/lib/expressir/p21.rb +6 -0
- data/lib/expressir/version.rb +1 -1
- data/lib/expressir.rb +1 -0
- data/lib/tasks/instance.rake +77 -0
- metadata +16 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Expressir
|
|
4
|
+
module Eval
|
|
5
|
+
# Population-scale rule engine (TODO.instance/02). Each rule
|
|
6
|
+
# resolves once per schema edition and applies to many instances.
|
|
7
|
+
# Entity WHERE rules scope over the entity and its subtypes
|
|
8
|
+
# (ISO 10303-11 9.2.1, applicability via P21::Typing); UNIQUE
|
|
9
|
+
# rules (9.2.2.1) compare value tuples across the whole
|
|
10
|
+
# population; global rules (9.4) evaluate once per rule with the
|
|
11
|
+
# FOR entities bound to their instance aggregates. Per 9.4 a rule
|
|
12
|
+
# is violated only when it evaluates to false.
|
|
13
|
+
class Population
|
|
14
|
+
Finding = Struct.new(:kind, :instance, :rule_id, :message,
|
|
15
|
+
keyword_init: true)
|
|
16
|
+
|
|
17
|
+
attr_reader :schema, :population, :typing
|
|
18
|
+
|
|
19
|
+
def self.check(schema, population, short_names: nil)
|
|
20
|
+
new(schema, population, short_names: short_names).check
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(schema, population, short_names: nil)
|
|
24
|
+
@schema = schema
|
|
25
|
+
@population = population
|
|
26
|
+
@typing = P21::Typing.new(schema, short_names: short_names)
|
|
27
|
+
@by_entity = nil
|
|
28
|
+
@slots = {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def check
|
|
32
|
+
where_findings + unique_findings + global_findings
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Instances conforming to the named entity, including subtypes.
|
|
36
|
+
def instances_for(entity)
|
|
37
|
+
by_entity[entity.id&.safe_downcase] || []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def by_entity
|
|
43
|
+
@by_entity ||= begin
|
|
44
|
+
index = Hash.new { |hash, key| hash[key] = [] }
|
|
45
|
+
@population.instances.each do |id, record|
|
|
46
|
+
@typing.of(record).each do |entity|
|
|
47
|
+
index[entity.id&.safe_downcase] << [id, record]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
index
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def where_findings
|
|
55
|
+
@schema.entities.flat_map do |entity|
|
|
56
|
+
rules = Array(entity.where_rules)
|
|
57
|
+
next [] if rules.empty?
|
|
58
|
+
|
|
59
|
+
instances_for(entity).flat_map do |(id, record)|
|
|
60
|
+
rules.filter_map do |rule|
|
|
61
|
+
where_finding(id, record, entity, rule)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def where_finding(id, record, entity, rule)
|
|
68
|
+
env = Rules.environment_for(@population, @schema, id, record, entity)
|
|
69
|
+
value = Expression.evaluate(rule.expression, env)
|
|
70
|
+
return unless false?(value)
|
|
71
|
+
|
|
72
|
+
Finding.new(kind: :where, instance: id, rule_id: rule.id,
|
|
73
|
+
message: "#{id}: #{entity.id}.#{rule.id} (9.4)")
|
|
74
|
+
rescue Expressir::Eval::Error => e
|
|
75
|
+
Finding.new(kind: :where, instance: id, rule_id: rule.id,
|
|
76
|
+
message: "#{id}: #{entity.id}.#{rule.id}: #{e.message}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def unique_findings
|
|
80
|
+
@schema.entities.flat_map do |entity|
|
|
81
|
+
Array(entity.unique_rules).flat_map do |rule|
|
|
82
|
+
unique_rule_findings(entity, rule)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# 9.2.2.1: the value tuple of the referenced attributes must be
|
|
88
|
+
# unique across all instances of the entity and its subtypes.
|
|
89
|
+
def unique_rule_findings(entity, rule)
|
|
90
|
+
seen = {}
|
|
91
|
+
instances_for(entity).filter_map do |(id, record)|
|
|
92
|
+
env = Rules.environment_for(@population, @schema, id, record,
|
|
93
|
+
entity)
|
|
94
|
+
instance = Values::Instance.new(id: id.delete_prefix("#"),
|
|
95
|
+
record: record)
|
|
96
|
+
key = unique_key(rule, entity, env, instance)
|
|
97
|
+
if key.nil?
|
|
98
|
+
next error_finding(:unique, id, rule.id,
|
|
99
|
+
"#{id}: UNIQUE '#{rule.id}' references an " \
|
|
100
|
+
"attribute outside '#{entity.id}' (9.2.2.1)")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
first = seen[key]
|
|
104
|
+
seen[key] = id
|
|
105
|
+
next unless first
|
|
106
|
+
|
|
107
|
+
Finding.new(kind: :unique, instance: id, rule_id: rule.id,
|
|
108
|
+
message: "#{id}: UNIQUE '#{rule.id}' duplicates " \
|
|
109
|
+
"#{first} (9.2.2.1)")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def unique_key(rule, entity, env, instance)
|
|
114
|
+
Array(rule.attributes).map do |reference|
|
|
115
|
+
name = reference.respond_to?(:id) ? reference.id : reference.to_s
|
|
116
|
+
return nil unless slots_for(entity).key?(name.safe_downcase)
|
|
117
|
+
|
|
118
|
+
Expression.instance_attribute(instance, name, env).inspect
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def slots_for(entity)
|
|
123
|
+
@slots[entity] ||= Environment.new(schema: @schema).slots_for(entity)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# 9.4 global rules: the FOR entities are bound, in the rule's
|
|
127
|
+
# scope, to the aggregates of all their instances.
|
|
128
|
+
def global_findings
|
|
129
|
+
Array(@schema.rules).flat_map do |rule|
|
|
130
|
+
env = global_environment(rule)
|
|
131
|
+
if env.nil?
|
|
132
|
+
next [error_finding(:global, nil, rule.id,
|
|
133
|
+
"global rule '#{rule.id}' names no entity " \
|
|
134
|
+
"(9.4)")]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
Array(rule.where_rules).filter_map do |where|
|
|
138
|
+
value = Expression.evaluate(where.expression, env)
|
|
139
|
+
next unless false?(value)
|
|
140
|
+
|
|
141
|
+
Finding.new(kind: :global, instance: nil, rule_id: rule.id,
|
|
142
|
+
message: "global rule '#{rule.id}' (9.4)")
|
|
143
|
+
rescue Expressir::Eval::Error => e
|
|
144
|
+
error_finding(:global, nil, rule.id,
|
|
145
|
+
"global rule '#{rule.id}': #{e.message}")
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def global_environment(rule)
|
|
151
|
+
env = Environment.new(schema: @schema, population: @population)
|
|
152
|
+
bound = 0
|
|
153
|
+
Array(rule.applies_to).each do |reference|
|
|
154
|
+
name = reference.respond_to?(:id) ? reference.id : nil
|
|
155
|
+
entity = name && @schema.entities.find do |candidate|
|
|
156
|
+
candidate.id&.safe_downcase == name.safe_downcase
|
|
157
|
+
end
|
|
158
|
+
return nil unless entity
|
|
159
|
+
|
|
160
|
+
env = env.define(
|
|
161
|
+
name.safe_downcase,
|
|
162
|
+
Values::Aggregate.of(
|
|
163
|
+
:list, instances_for(entity).map do |(id, record)|
|
|
164
|
+
Values::Instance.new(id: id.delete_prefix("#"), record: record)
|
|
165
|
+
end
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
bound += 1
|
|
169
|
+
end
|
|
170
|
+
bound.positive? ? env : nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def error_finding(kind, instance, rule_id, message)
|
|
174
|
+
Finding.new(kind: kind, instance: instance, rule_id: rule_id,
|
|
175
|
+
message: message)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def false?(value)
|
|
179
|
+
return true if value == Values::LOGICAL_FALSE
|
|
180
|
+
return value.value == Values::LOGICAL_FALSE if value.is_a?(Values::Logical)
|
|
181
|
+
|
|
182
|
+
false
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
data/lib/expressir/eval/rules.rb
CHANGED
data/lib/expressir/eval.rb
CHANGED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Expressir
|
|
4
|
+
module Mapping
|
|
5
|
+
# ARM↔MIM instance mapping verification (TODO.instance/04): given
|
|
6
|
+
# a module's mapping tables (Mapping::Document) and two
|
|
7
|
+
# populations — an ARM-typed one and a MIM one — verify the MIM
|
|
8
|
+
# population is the image of the ARM population. Each application
|
|
9
|
+
# element is an instance predicate: every ARM instance of the
|
|
10
|
+
# element's entity must have a MIM image whose typing includes the
|
|
11
|
+
# mapped MIM entity, whose element refpath (or an alt_map route)
|
|
12
|
+
# resolves from that image, whose attribute-mapping refpaths
|
|
13
|
+
# terminate at values equal to the ARM attribute values, and whose
|
|
14
|
+
# inline element rules evaluate true (Eval WHERE semantics, self
|
|
15
|
+
# bound to the image).
|
|
16
|
+
#
|
|
17
|
+
# Reference values correspond across the two populations through
|
|
18
|
+
# the mapping itself: an ARM `#n` terminal matches the MIM image
|
|
19
|
+
# of ARM instance n under the element that maps its keyword.
|
|
20
|
+
#
|
|
21
|
+
# Instance-level path walking covers the mapping-corpus RefPath
|
|
22
|
+
# constructs: bare and attribute-qualified nodes, `->`/`<-`
|
|
23
|
+
# navigation, `<=`/`=>` restatements, the §5 `||supertype||` and
|
|
24
|
+
# `*tree*` markers, and `{entity.attr = literal}` constraints
|
|
25
|
+
# (with `!` negation). Rules fields that name global rules rather
|
|
26
|
+
# than inline expressions are the domain of Mapping.rule_issues;
|
|
27
|
+
# they are not re-checked per instance here. Complex (multi-role)
|
|
28
|
+
# records are outside v1 scope: a complex candidate fails its
|
|
29
|
+
# route rather than silently passing.
|
|
30
|
+
class InstanceVerification
|
|
31
|
+
Finding = Struct.new(:arm_instance, :entity, :message, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
# Part 21 value normalization for equality: enumerations compare
|
|
34
|
+
# by name, typed parameters by keyword+value.
|
|
35
|
+
module ValueCompare
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
def comparable(value)
|
|
39
|
+
case value
|
|
40
|
+
when P21::Enumeration then value.name
|
|
41
|
+
when P21::TypedValue then [value.keyword, comparable(value.value)]
|
|
42
|
+
when Array then value.map { |item| comparable(item) }
|
|
43
|
+
else value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
include ValueCompare
|
|
49
|
+
|
|
50
|
+
attr_reader :findings
|
|
51
|
+
|
|
52
|
+
def initialize(mapping:, arm_schema:, mim_schema:,
|
|
53
|
+
arm_population:, mim_population:)
|
|
54
|
+
@mapping = mapping
|
|
55
|
+
@arm_schema = arm_schema
|
|
56
|
+
@mim_schema = mim_schema
|
|
57
|
+
@arm_population = arm_population
|
|
58
|
+
@mim_population = mim_population
|
|
59
|
+
@arm_typing = P21::Typing.new(arm_schema)
|
|
60
|
+
@mim_typing = P21::Typing.new(mim_schema)
|
|
61
|
+
@arm_slots = Eval::Environment.new(schema: arm_schema)
|
|
62
|
+
@mim_slots = Eval::Environment.new(schema: mim_schema)
|
|
63
|
+
@findings = []
|
|
64
|
+
@image_cache = {}
|
|
65
|
+
@rule_cache = {}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def verify
|
|
69
|
+
@mapping.ae.to_a.each { |element| verify_element(element) }
|
|
70
|
+
@findings
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def verify_element(element)
|
|
76
|
+
arm_entity = find_entity(@arm_schema, element.entity)
|
|
77
|
+
unless arm_entity
|
|
78
|
+
@findings << Finding.new(arm_instance: nil, entity: element.entity,
|
|
79
|
+
message: "ARM schema declares no entity " \
|
|
80
|
+
"'#{element.entity}'")
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
arm_instances(arm_entity).each do |(id, record)|
|
|
85
|
+
next if image_of(element, record, id, {}.compare_by_identity)
|
|
86
|
+
|
|
87
|
+
@findings << Finding.new(
|
|
88
|
+
arm_instance: id, entity: element.entity,
|
|
89
|
+
message: "no MIM image of #{element.entity} instance #{id} " \
|
|
90
|
+
"through the mapping"
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def arm_instances(entity)
|
|
96
|
+
@arm_population.instances.filter_map do |id, record|
|
|
97
|
+
next unless typed?(@arm_typing, record, entity)
|
|
98
|
+
|
|
99
|
+
[id, record]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The MIM image id of one ARM instance, or nil. Tries the
|
|
104
|
+
# element's primary route then each alt_map route; memoized so
|
|
105
|
+
# cross-population reference correspondence stays linear.
|
|
106
|
+
def image_of(element, arm_record, arm_id, seen)
|
|
107
|
+
key = [element.object_id, arm_id]
|
|
108
|
+
return @image_cache[key] if @image_cache.key?(key)
|
|
109
|
+
return nil if seen[arm_id]
|
|
110
|
+
|
|
111
|
+
seen[arm_id] = true
|
|
112
|
+
@image_cache[key] = routes(element).filter_map do |route|
|
|
113
|
+
mim_entity = find_entity(@mim_schema, route[:entity])
|
|
114
|
+
next unless mim_entity
|
|
115
|
+
|
|
116
|
+
mim_candidates(mim_entity).find do |(mim_id, mim_record)|
|
|
117
|
+
route_matches?(route, element, arm_record,
|
|
118
|
+
arm_entity_of(element), mim_id, mim_record,
|
|
119
|
+
mim_entity, seen)
|
|
120
|
+
end&.first
|
|
121
|
+
end.first
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def routes(element)
|
|
125
|
+
[{ entity: Mapping.entity_item(element.aimelt),
|
|
126
|
+
path: element.refpath&.content }].concat(
|
|
127
|
+
element.alt_map.to_a.map do |alt|
|
|
128
|
+
{ entity: Mapping.entity_item(alt.aimelt),
|
|
129
|
+
path: alt.refpath&.content }
|
|
130
|
+
end,
|
|
131
|
+
).reject { |route| route[:entity].nil? || route[:entity].empty? }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def route_matches?(route, element, arm_record, arm_entity,
|
|
135
|
+
mim_id, mim_record, mim_entity, seen)
|
|
136
|
+
walk = Walker.new(@mim_population, @mim_typing, @mim_slots, @mim_schema)
|
|
137
|
+
walk.follow(route[:path], mim_id, mim_record, mim_entity) if route[:path]
|
|
138
|
+
element.aa.to_a.all? do |aa|
|
|
139
|
+
attribute_matches?(aa, arm_record, arm_entity,
|
|
140
|
+
mim_id, mim_record, mim_entity, seen)
|
|
141
|
+
end && rules_hold?(element, mim_entity, mim_id, mim_record)
|
|
142
|
+
rescue Walker::BrokenPath
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def attribute_matches?(attr_mapping, arm_record, arm_entity,
|
|
147
|
+
mim_id, mim_record, mim_entity, seen)
|
|
148
|
+
arm_value = arm_attribute_value(arm_record, arm_entity, attr_mapping)
|
|
149
|
+
walk = Walker.new(@mim_population, @mim_typing, @mim_slots, @mim_schema)
|
|
150
|
+
walk.follow(attr_mapping.refpath&.content, mim_id, mim_record, mim_entity)
|
|
151
|
+
values_match?(walk.terminal, arm_value, seen)
|
|
152
|
+
rescue Walker::BrokenPath
|
|
153
|
+
false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def values_match?(mim_value, arm_value, seen)
|
|
157
|
+
comparable(mim_value) == comparable(arm_value) ||
|
|
158
|
+
reference_corresponds?(mim_value, arm_value, seen)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Reference values correspond through the mapping: the ARM `#n`
|
|
162
|
+
# must map to exactly the MIM instance the path reached.
|
|
163
|
+
def reference_corresponds?(mim_value, arm_value, seen)
|
|
164
|
+
return false unless reference?(mim_value) && reference?(arm_value)
|
|
165
|
+
|
|
166
|
+
arm_record = @arm_population.instances[arm_value]
|
|
167
|
+
return false unless arm_record
|
|
168
|
+
|
|
169
|
+
element = @mapping.ae.to_a.find do |ae|
|
|
170
|
+
ae.entity&.safe_downcase == arm_record.keyword&.safe_downcase
|
|
171
|
+
end
|
|
172
|
+
return false unless element
|
|
173
|
+
|
|
174
|
+
image_of(element, arm_record, arm_value, seen) == mim_value
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def reference?(value)
|
|
178
|
+
value.is_a?(String) && value.start_with?("#")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def arm_attribute_value(arm_record, arm_entity, attr_mapping)
|
|
182
|
+
name = attr_mapping.attribute.to_s.split(".").last
|
|
183
|
+
index = @arm_slots.slots_for(arm_entity)[name&.safe_downcase]
|
|
184
|
+
raise Walker::BrokenPath, "ARM entity has no attribute '#{name}'" unless index
|
|
185
|
+
|
|
186
|
+
arm_record.params[index]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Inline expression rules are instance predicates; a rules field
|
|
190
|
+
# that is not an expression (a named global rule) is left to
|
|
191
|
+
# Mapping.rule_issues.
|
|
192
|
+
def rules_hold?(element, mim_entity, mim_id, mim_record)
|
|
193
|
+
content = element.rules&.content
|
|
194
|
+
return true unless content
|
|
195
|
+
|
|
196
|
+
expression = @rule_cache[content] ||= parse_rule_expression(content)
|
|
197
|
+
return true unless expression
|
|
198
|
+
|
|
199
|
+
env = Eval::Rules.environment_for(@mim_population, @mim_schema,
|
|
200
|
+
mim_id, mim_record, mim_entity)
|
|
201
|
+
!logical_false?(Eval::Expression.evaluate(expression, env))
|
|
202
|
+
rescue Eval::Error, Walker::BrokenPath
|
|
203
|
+
false
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def parse_rule_expression(content)
|
|
207
|
+
parsed = Expressir::Express::Parser.from_exp(
|
|
208
|
+
"SCHEMA mapping_rule_wrap; ENTITY wrap; " \
|
|
209
|
+
"WHERE rule_expr : #{content}; END_ENTITY; END_SCHEMA;",
|
|
210
|
+
)
|
|
211
|
+
parsed.schemas.first.entities.first.where_rules.first.expression
|
|
212
|
+
rescue StandardError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def logical_false?(result)
|
|
217
|
+
return [false, Eval::Values::LOGICAL_FALSE].include?(result) unless
|
|
218
|
+
result.is_a?(Eval::Values::Logical)
|
|
219
|
+
|
|
220
|
+
result.value == Eval::Values::LOGICAL_FALSE
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def mim_candidates(entity)
|
|
224
|
+
@mim_population.instances.filter_map do |id, record|
|
|
225
|
+
next unless typed?(@mim_typing, record, entity)
|
|
226
|
+
|
|
227
|
+
[id, record]
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def typed?(typing, record, entity)
|
|
232
|
+
typing.issues(record).empty? &&
|
|
233
|
+
typing.of(record)
|
|
234
|
+
.any? { |decl| decl.id&.safe_downcase == entity.id&.safe_downcase }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def arm_entity_of(element)
|
|
238
|
+
find_entity(@arm_schema, element.entity)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def find_entity(schema, name)
|
|
242
|
+
key = Mapping.entity_item(name).to_s.safe_downcase
|
|
243
|
+
Array(schema.entities).find { |entity| entity.id&.safe_downcase == key }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Walks a reference path through actual population instances,
|
|
247
|
+
# starting at a candidate MIM image. Raises BrokenPath the
|
|
248
|
+
# moment the population contradicts the path.
|
|
249
|
+
class Walker
|
|
250
|
+
class BrokenPath < StandardError
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
include ValueCompare
|
|
254
|
+
|
|
255
|
+
attr_reader :terminal
|
|
256
|
+
|
|
257
|
+
def initialize(population, typing, slots, schema)
|
|
258
|
+
@population = population
|
|
259
|
+
@typing = typing
|
|
260
|
+
@slots = slots
|
|
261
|
+
@schema = schema
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def follow(content, id, record, entity)
|
|
265
|
+
@id = id
|
|
266
|
+
@record = record
|
|
267
|
+
@entity = entity
|
|
268
|
+
@last_attr = nil
|
|
269
|
+
@terminal = nil
|
|
270
|
+
RefPath.parse(content).steps.each { |step| follow_step(step) } if content
|
|
271
|
+
self
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
private
|
|
275
|
+
|
|
276
|
+
def follow_step(step)
|
|
277
|
+
return if marker_step?(step)
|
|
278
|
+
return constraint(step) if step.operator == "{"
|
|
279
|
+
return move_to(step.name) if step.name && %w[<= => || = *].include?(step.operator)
|
|
280
|
+
return navigate(step) if step.operator == "->"
|
|
281
|
+
return inverse(step) if step.operator == "<-"
|
|
282
|
+
return step_over(step) if step.name
|
|
283
|
+
|
|
284
|
+
raise BrokenPath, "unsupported path step '#{step.operator}'"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def marker_step?(step)
|
|
288
|
+
step.name.nil? && step.attribute.nil? && step.link.nil? &&
|
|
289
|
+
step.operator != "{" && step.operator != "(" && step.operator != ")"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# {entity.attr = literal} against the instance the walk sits
|
|
293
|
+
# on; `!{` negates.
|
|
294
|
+
def constraint(step)
|
|
295
|
+
unless step.name.nil? ||
|
|
296
|
+
step.name&.safe_downcase == @entity&.id&.safe_downcase
|
|
297
|
+
raise BrokenPath,
|
|
298
|
+
"constraint on '#{step.name}' does not name the " \
|
|
299
|
+
"walk's entity"
|
|
300
|
+
end
|
|
301
|
+
return unless step.attribute
|
|
302
|
+
|
|
303
|
+
actual = read(step.attribute)
|
|
304
|
+
expected = step.literal.to_s
|
|
305
|
+
expected = expected[1..-2] if expected.start_with?("'")
|
|
306
|
+
holds = comparable(actual).to_s.casecmp?(expected)
|
|
307
|
+
holds = !holds if step.negated
|
|
308
|
+
return if holds
|
|
309
|
+
|
|
310
|
+
raise BrokenPath,
|
|
311
|
+
"constraint '#{step.name}.#{step.attribute} = " \
|
|
312
|
+
"#{step.literal}' does not hold"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The instance must conform to the named declaration; the walk
|
|
316
|
+
# context moves there so later slots resolve against it.
|
|
317
|
+
def move_to(name)
|
|
318
|
+
target = find_entity(name)
|
|
319
|
+
raise BrokenPath, "no declaration '#{name}'" unless target
|
|
320
|
+
|
|
321
|
+
conforms = @typing.of(@record)
|
|
322
|
+
.any? { |decl| decl.id&.safe_downcase == target.id&.safe_downcase }
|
|
323
|
+
raise BrokenPath, "instance does not conform to '#{name}'" unless conforms
|
|
324
|
+
|
|
325
|
+
@entity = target
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# A bare or attribute-qualified node: restatement plus, when
|
|
329
|
+
# qualified, the attribute read — the terminal value, and the
|
|
330
|
+
# attribute a following `->` navigates through.
|
|
331
|
+
def step_over(step)
|
|
332
|
+
move_to(step.name)
|
|
333
|
+
@last_attr = step.attribute
|
|
334
|
+
@terminal = read(step.attribute) if step.attribute
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# `attr -> entity`: the recorded attribute must hold a
|
|
338
|
+
# reference to an instance of the named entity.
|
|
339
|
+
def navigate(step)
|
|
340
|
+
attribute = step.attribute || @last_attr
|
|
341
|
+
raise BrokenPath, "'->' without an attribute" unless attribute
|
|
342
|
+
|
|
343
|
+
value = read(attribute)
|
|
344
|
+
target = @population.instances[value.to_s]
|
|
345
|
+
raise BrokenPath, "'#{attribute}' does not hold a reference" unless target
|
|
346
|
+
|
|
347
|
+
@record = target
|
|
348
|
+
@id = value.to_s
|
|
349
|
+
move_to(step.name)
|
|
350
|
+
@last_attr = nil
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# `<- entity.attr`: some instance of the named entity must
|
|
354
|
+
# reference the current one through the attribute; the walk
|
|
355
|
+
# moves there.
|
|
356
|
+
def inverse(step)
|
|
357
|
+
raise BrokenPath, "'<-' without an attribute" unless step.attribute
|
|
358
|
+
|
|
359
|
+
found = @population.instances.find do |_, candidate|
|
|
360
|
+
keyword = candidate.keyword&.safe_downcase
|
|
361
|
+
next false unless keyword == step.name&.safe_downcase
|
|
362
|
+
|
|
363
|
+
read_attribute(candidate, step.attribute) == @id
|
|
364
|
+
end
|
|
365
|
+
unless found
|
|
366
|
+
raise BrokenPath,
|
|
367
|
+
"no '#{step.name}' instance references '#{@id}' " \
|
|
368
|
+
"through '#{step.attribute}'"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
@record = found[1]
|
|
372
|
+
@id = found[0]
|
|
373
|
+
move_to(step.name)
|
|
374
|
+
@last_attr = step.attribute
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def read(attribute)
|
|
378
|
+
read_attribute(@record, attribute, entity: @entity)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def read_attribute(record, attribute, entity: nil)
|
|
382
|
+
target = entity || entity_for_record(record)
|
|
383
|
+
index = @slots.slots_for(target)[attribute&.safe_downcase]
|
|
384
|
+
unless index
|
|
385
|
+
raise BrokenPath,
|
|
386
|
+
"no attribute '#{attribute}' on '#{target&.id}'"
|
|
387
|
+
end
|
|
388
|
+
raise BrokenPath, "complex records are outside v1 scope" if
|
|
389
|
+
record.is_a?(P21::ComplexRecord)
|
|
390
|
+
|
|
391
|
+
record.params[index]
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def entity_for_record(record)
|
|
395
|
+
find_entity(record.keyword)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def find_entity(name)
|
|
399
|
+
key = name&.safe_downcase
|
|
400
|
+
Array(@schema.entities).find { |entity| entity.id&.safe_downcase == key }
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
data/lib/expressir/mapping.rb
CHANGED