lutaml-lml 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.
- checksums.yaml +7 -0
- data/lib/lutaml/lml/association_label_resolver.rb +52 -0
- data/lib/lutaml/lml/cli.rb +262 -0
- data/lib/lutaml/lml/data_processor/attribute_processing.rb +81 -0
- data/lib/lutaml/lml/data_processor/collection_processing.rb +37 -0
- data/lib/lutaml/lml/data_processor/instance_processing.rb +63 -0
- data/lib/lutaml/lml/data_processor/value_processing.rb +98 -0
- data/lib/lutaml/lml/data_processor/view_processing.rb +25 -0
- data/lib/lutaml/lml/data_processor.rb +49 -0
- data/lib/lutaml/lml/document_builder.rb +139 -0
- data/lib/lutaml/lml/executor/adapter_helpers.rb +45 -0
- data/lib/lutaml/lml/executor/condition_evaluator.rb +169 -0
- data/lib/lutaml/lml/executor/csv_adapter.rb +88 -0
- data/lib/lutaml/lml/executor/format_adapter.rb +54 -0
- data/lib/lutaml/lml/executor/xml_adapter.rb +102 -0
- data/lib/lutaml/lml/executor.rb +89 -0
- data/lib/lutaml/lml/format/adapter/document.rb +11 -0
- data/lib/lutaml/lml/format/adapter/mapping.rb +19 -0
- data/lib/lutaml/lml/format/adapter/standard_adapter.rb +127 -0
- data/lib/lutaml/lml/format/adapter/transform.rb +11 -0
- data/lib/lutaml/lml/format.rb +29 -0
- data/lib/lutaml/lml/formatter/base.rb +79 -0
- data/lib/lutaml/lml/formatter/graphviz/document_formatter.rb +89 -0
- data/lib/lutaml/lml/formatter/graphviz/html_builder.rb +72 -0
- data/lib/lutaml/lml/formatter/graphviz/node_formatter.rb +74 -0
- data/lib/lutaml/lml/formatter/graphviz/relationship_formatter.rb +130 -0
- data/lib/lutaml/lml/formatter/graphviz.rb +90 -0
- data/lib/lutaml/lml/formatter.rb +8 -0
- data/lib/lutaml/lml/grammar/concerns/associations.rb +76 -0
- data/lib/lutaml/lml/grammar/concerns/attributes.rb +126 -0
- data/lib/lutaml/lml/grammar/concerns/data_structures.rb +84 -0
- data/lib/lutaml/lml/grammar/concerns/definitions.rb +222 -0
- data/lib/lutaml/lml/grammar/concerns/instance_rules.rb +59 -0
- data/lib/lutaml/lml/grammar/concerns/primitives.rb +89 -0
- data/lib/lutaml/lml/grammar/concerns/view_rules.rb +34 -0
- data/lib/lutaml/lml/grammar/concerns.rb +17 -0
- data/lib/lutaml/lml/grammar/core.rb +71 -0
- data/lib/lutaml/lml/grammar/full.rb +12 -0
- data/lib/lutaml/lml/grammar/instances.rb +38 -0
- data/lib/lutaml/lml/grammar.rb +12 -0
- data/lib/lutaml/lml/has_attributes.rb +14 -0
- data/lib/lutaml/lml/import_resolver.rb +89 -0
- data/lib/lutaml/lml/layout/engine.rb +17 -0
- data/lib/lutaml/lml/layout/graph_viz_engine.rb +19 -0
- data/lib/lutaml/lml/layout.rb +8 -0
- data/lib/lutaml/lml/model_compiler.rb +325 -0
- data/lib/lutaml/lml/models/action.rb +10 -0
- data/lib/lutaml/lml/models/association.rb +26 -0
- data/lib/lutaml/lml/models/cardinality.rb +10 -0
- data/lib/lutaml/lml/models/collection.rb +11 -0
- data/lib/lutaml/lml/models/constraint.rb +20 -0
- data/lib/lutaml/lml/models/data_type.rb +31 -0
- data/lib/lutaml/lml/models/diagram.rb +17 -0
- data/lib/lutaml/lml/models/document.rb +45 -0
- data/lib/lutaml/lml/models/enum.rb +28 -0
- data/lib/lutaml/lml/models/fidelity.rb +10 -0
- data/lib/lutaml/lml/models/group.rb +11 -0
- data/lib/lutaml/lml/models/instance.rb +13 -0
- data/lib/lutaml/lml/models/instance_collection.rb +12 -0
- data/lib/lutaml/lml/models/instances_export.rb +10 -0
- data/lib/lutaml/lml/models/instances_import.rb +11 -0
- data/lib/lutaml/lml/models/operation.rb +23 -0
- data/lib/lutaml/lml/models/operation_parameter.rb +11 -0
- data/lib/lutaml/lml/models/package.rb +22 -0
- data/lib/lutaml/lml/models/primitive_type.rb +26 -0
- data/lib/lutaml/lml/models/top_element_attribute.rb +31 -0
- data/lib/lutaml/lml/models/uml_class.rb +84 -0
- data/lib/lutaml/lml/models/value.rb +12 -0
- data/lib/lutaml/lml/models/view_filter.rb +11 -0
- data/lib/lutaml/lml/models/view_import.rb +11 -0
- data/lib/lutaml/lml/parser.rb +22 -0
- data/lib/lutaml/lml/pipeline.rb +64 -0
- data/lib/lutaml/lml/preprocessor.rb +56 -0
- data/lib/lutaml/lml/transform.rb +20 -0
- data/lib/lutaml/lml/version.rb +7 -0
- data/lib/lutaml/lml/view_resolver.rb +48 -0
- data/lib/lutaml/lml/yaml_parser.rb +17 -0
- data/lib/lutaml/lml.rb +67 -0
- metadata +178 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Lml
|
|
5
|
+
class DocumentBuilder
|
|
6
|
+
# Default registry: builder keys → LML model classes.
|
|
7
|
+
# Callers needing a different mapping can pass their own registry
|
|
8
|
+
# to `new`; this constant documents the default composition.
|
|
9
|
+
DEFAULT_REGISTRY = {
|
|
10
|
+
document: ::Lutaml::Lml::Document,
|
|
11
|
+
package: ::Lutaml::Lml::Package,
|
|
12
|
+
class: ::Lutaml::Lml::UmlClass,
|
|
13
|
+
enum: ::Lutaml::Lml::Enum,
|
|
14
|
+
data_type: ::Lutaml::Lml::DataType,
|
|
15
|
+
diagram: ::Lutaml::Lml::Diagram,
|
|
16
|
+
attribute: ::Lutaml::Lml::TopElementAttribute,
|
|
17
|
+
cardinality: ::Lutaml::Lml::Cardinality,
|
|
18
|
+
association: ::Lutaml::Lml::Association,
|
|
19
|
+
operation: ::Lutaml::Lml::Operation,
|
|
20
|
+
constraint: ::Lutaml::Lml::Constraint,
|
|
21
|
+
value: ::Lutaml::Lml::Value,
|
|
22
|
+
view_import: ::Lutaml::Lml::ViewImport,
|
|
23
|
+
view_filter: ::Lutaml::Lml::ViewFilter
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :registry
|
|
27
|
+
|
|
28
|
+
def initialize(registry = DEFAULT_REGISTRY)
|
|
29
|
+
@registry = registry
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
FACTORY_KEYS = %i[
|
|
33
|
+
document package class enum data_type diagram view_import view_filter
|
|
34
|
+
attribute association operation constraint value cardinality
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
MEMBER_KEY_MAP = {
|
|
38
|
+
packages: :package,
|
|
39
|
+
classes: :class,
|
|
40
|
+
enums: :enum,
|
|
41
|
+
data_types: :data_type,
|
|
42
|
+
diagrams: :diagram,
|
|
43
|
+
view_imports: :view_import,
|
|
44
|
+
attributes: :attribute,
|
|
45
|
+
associations: :association,
|
|
46
|
+
operations: :operation,
|
|
47
|
+
constraints: :constraint,
|
|
48
|
+
values: :value
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
FACTORY_KEYS.each do |key|
|
|
52
|
+
define_method(:"build_#{key}") { |hash| build(key, hash) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build(key, hash)
|
|
56
|
+
@registry.fetch(key).new.tap { |model| set_model(model, hash) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def set_model(model, hash)
|
|
62
|
+
hash = build_members(model, hash)
|
|
63
|
+
set_model_attributes(model, hash)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def set_model_attributes(model, hash)
|
|
67
|
+
hash.each do |key, value|
|
|
68
|
+
value = sanitize_definition(value) if key == :definition
|
|
69
|
+
apply_attribute(model, key, value)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def build_members(model, hash)
|
|
74
|
+
members = hash.delete(:members)
|
|
75
|
+
members.to_a.each do |member_hash|
|
|
76
|
+
add_members(model, member_hash)
|
|
77
|
+
set_model_attributes(model, member_hash)
|
|
78
|
+
end
|
|
79
|
+
hash
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def sanitize_definition(value)
|
|
83
|
+
value.to_s.gsub(/\\}/, '}').gsub(/\\{/, '{')
|
|
84
|
+
.split("\n").map(&:strip).join("\n")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def apply_attribute(model, key, value)
|
|
88
|
+
return unless model.class.attributes.key?(key.to_sym)
|
|
89
|
+
|
|
90
|
+
if model.class.attributes[key.to_sym].options[:collection]
|
|
91
|
+
append_collection(model, key, value)
|
|
92
|
+
else
|
|
93
|
+
model.public_send("#{key}=", value)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def append_collection(model, key, value)
|
|
98
|
+
values = model.public_send(key).to_a
|
|
99
|
+
value.is_a?(Array) ? values.concat(value) : values << value
|
|
100
|
+
model.public_send("#{key}=", values)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def add_members(model, hash)
|
|
104
|
+
MEMBER_KEY_MAP.each do |plural_key, singular_key|
|
|
105
|
+
data = hash.delete(plural_key)
|
|
106
|
+
next if data.nil?
|
|
107
|
+
|
|
108
|
+
member = build(singular_key, data)
|
|
109
|
+
ensure_collection(model, plural_key) << member
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
remap_filter_keys(hash, model)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def ensure_collection(model, key)
|
|
116
|
+
model.public_send(key) || begin
|
|
117
|
+
model.public_send("#{key}=", [])
|
|
118
|
+
model.public_send(key)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def remap_filter_keys(hash, model)
|
|
123
|
+
return unless model.is_a?(Document)
|
|
124
|
+
|
|
125
|
+
if hash.key?(:show_list)
|
|
126
|
+
hash[:show_filter] = build_view_filter(hash.delete(:show_list))
|
|
127
|
+
end
|
|
128
|
+
if hash.key?(:hide_list)
|
|
129
|
+
hash[:hide_filter] = build_view_filter(hash.delete(:hide_list))
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_view_filter(entity_names)
|
|
134
|
+
names = entity_names.is_a?(Array) ? entity_names : [entity_names]
|
|
135
|
+
ViewFilter.new(entity_names: names.map(&:to_s))
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Lml
|
|
5
|
+
class Executor
|
|
6
|
+
# Shared helpers for the I/O adapters. Both CsvAdapter and XmlAdapter
|
|
7
|
+
# need to:
|
|
8
|
+
#
|
|
9
|
+
# - resolve a `map_to` reference to a compiled class
|
|
10
|
+
# - look up attribute values by name in an import/export definition
|
|
11
|
+
# - find the compiled class that an instance belongs to (for export)
|
|
12
|
+
#
|
|
13
|
+
# This module owns those three concerns so each adapter can stay
|
|
14
|
+
# focused on its format-specific I/O.
|
|
15
|
+
module AdapterHelpers
|
|
16
|
+
# Returns the compiled class referenced by the `map_to` attribute,
|
|
17
|
+
# or nil if no such attribute or no such compiled class.
|
|
18
|
+
def resolve_target_class(attributes, compiled)
|
|
19
|
+
attr = find_attribute(attributes, "map_to")
|
|
20
|
+
return nil unless attr
|
|
21
|
+
|
|
22
|
+
compiled[attr.value.to_s]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns the string value of the named attribute, or nil.
|
|
26
|
+
def attribute_value(attributes, name)
|
|
27
|
+
attr = find_attribute(attributes, name)
|
|
28
|
+
attr ? attr.value.to_s : nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Walks an attributes collection looking for an entry whose
|
|
32
|
+
# `.name` matches `name`. Returns the attribute, or nil.
|
|
33
|
+
def find_attribute(attributes, name)
|
|
34
|
+
Array(attributes).find { |a| a.name == name }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns [class_name, klass] for the first compiled class whose
|
|
38
|
+
# instances include `first_instance`, or nil if no match.
|
|
39
|
+
def find_class_for_instance(first_instance, compiled)
|
|
40
|
+
compiled.find { |_name, klass| first_instance.is_a?(klass) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Lml
|
|
5
|
+
class Executor
|
|
6
|
+
# Evaluates collection validation conditions against instance data.
|
|
7
|
+
#
|
|
8
|
+
# Conditions are simple DSL strings like "count >= 3" or
|
|
9
|
+
# "all? { |i| i.components.count > 0 }". The evaluator parses a safe
|
|
10
|
+
# subset of forms — it never calls `eval` on arbitrary Ruby.
|
|
11
|
+
#
|
|
12
|
+
# Supported condition forms:
|
|
13
|
+
# "count >= N" — collection size comparison
|
|
14
|
+
# "count == N", "count <= N", etc.
|
|
15
|
+
# "all? { |i| i.path OP literal }" — every instance matches
|
|
16
|
+
# "any? { |i| i.path OP literal }" — at least one instance matches
|
|
17
|
+
#
|
|
18
|
+
# The block predicate supports a single comparison of an instance
|
|
19
|
+
# attribute path (e.g. `i.components.count`, `i.name`) against a
|
|
20
|
+
# literal (number, quoted string, true/false/nil) using one of
|
|
21
|
+
# `>, >=, <, <=, ==, !=`.
|
|
22
|
+
#
|
|
23
|
+
class ConditionEvaluator
|
|
24
|
+
ConditionError = Class.new(StandardError)
|
|
25
|
+
|
|
26
|
+
BLOCK_PREFIX = /\A\s*(all|any)\?/.freeze
|
|
27
|
+
BLOCK_VAR = /\A\s*\|(\w+)\|/.freeze
|
|
28
|
+
COMPARISON = /\A\s*(.+?)\s*(>=|<=|==|!=|>|<)\s*(.+?)\s*\z/.freeze
|
|
29
|
+
|
|
30
|
+
# Evaluate all validation conditions against a collection of instances.
|
|
31
|
+
# Returns an array of error strings (empty if all pass).
|
|
32
|
+
def self.evaluate(collection, instances)
|
|
33
|
+
return [] unless collection.validations&.any?
|
|
34
|
+
return [] unless collection.is_a?(Collection)
|
|
35
|
+
|
|
36
|
+
new(instances).evaluate_all(collection.validations)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(instances)
|
|
40
|
+
@instances = instances
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def evaluate_all(conditions)
|
|
44
|
+
conditions.filter_map do |condition|
|
|
45
|
+
evaluate(condition)
|
|
46
|
+
rescue ConditionError => e
|
|
47
|
+
"Validation failed: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def evaluate(condition)
|
|
54
|
+
return evaluate_block(condition) if condition.match?(/\A\s*(all|any)\?\s*\{/)
|
|
55
|
+
return evaluate_count(condition) if condition.match?(/\bcount\s*[<>=]+/)
|
|
56
|
+
|
|
57
|
+
raise ConditionError, "Unsupported condition: #{condition}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def evaluate_count(condition)
|
|
61
|
+
match = condition.match(/\bcount\s*(>=|<=|==|>|<)\s*(\d+)/)
|
|
62
|
+
raise ConditionError, "Invalid count condition: #{condition}" unless match
|
|
63
|
+
|
|
64
|
+
operator = match[1]
|
|
65
|
+
expected = match[2].to_i
|
|
66
|
+
actual = @instances.length
|
|
67
|
+
|
|
68
|
+
compare(actual, operator, expected) ||
|
|
69
|
+
raise(ConditionError, "#{condition} (got #{actual})")
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def evaluate_block(condition)
|
|
74
|
+
quantifier, predicate = parse_block(condition)
|
|
75
|
+
raise ConditionError, "Invalid block condition: #{condition}" if quantifier.nil?
|
|
76
|
+
|
|
77
|
+
comparator = build_predicate(predicate)
|
|
78
|
+
satisfied = @instances.public_send("#{quantifier}?") do |instance|
|
|
79
|
+
comparator.call(instance)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
return nil if satisfied
|
|
83
|
+
|
|
84
|
+
raise ConditionError, "#{condition} (failed for #{quantifier == "all" ? "at least one" : "all"} instance)"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parse_block(condition)
|
|
88
|
+
stripped = condition.strip
|
|
89
|
+
prefix = stripped.match(BLOCK_PREFIX)
|
|
90
|
+
return [nil, nil] unless prefix
|
|
91
|
+
|
|
92
|
+
open_idx = stripped.index("{")
|
|
93
|
+
close_idx = stripped.rindex("}")
|
|
94
|
+
return [nil, nil] unless open_idx && close_idx && close_idx > open_idx
|
|
95
|
+
|
|
96
|
+
body = stripped[(open_idx + 1)...close_idx].strip
|
|
97
|
+
var_match = body.match(BLOCK_VAR)
|
|
98
|
+
return [nil, nil] unless var_match
|
|
99
|
+
|
|
100
|
+
predicate = body[var_match[0].length..].strip
|
|
101
|
+
[prefix[1], predicate]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Parses a single comparison predicate "i.path OP literal" into a
|
|
105
|
+
# callable that, given an instance, returns true/false.
|
|
106
|
+
def build_predicate(predicate)
|
|
107
|
+
match = predicate.match(COMPARISON)
|
|
108
|
+
raise ConditionError, "Unsupported predicate: #{predicate}" unless match
|
|
109
|
+
|
|
110
|
+
lhs = match[1]
|
|
111
|
+
operator = match[2]
|
|
112
|
+
rhs = match[3]
|
|
113
|
+
|
|
114
|
+
getter = attribute_getter(lhs)
|
|
115
|
+
expected = parse_literal(rhs)
|
|
116
|
+
|
|
117
|
+
lambda do |instance|
|
|
118
|
+
actual = getter.call(instance)
|
|
119
|
+
compare(actual, operator, expected)
|
|
120
|
+
rescue NoMethodError
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Parses "i.attr.subattr..." into a callable on an instance.
|
|
126
|
+
def attribute_getter(path)
|
|
127
|
+
parts = path.strip.split(".")
|
|
128
|
+
raise ConditionError, "Invalid attribute path: #{path}" if parts.empty?
|
|
129
|
+
|
|
130
|
+
unless parts.first == "i"
|
|
131
|
+
raise ConditionError, "Block predicate must reference i: #{path}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
chain = parts.drop(1)
|
|
135
|
+
lambda do |instance|
|
|
136
|
+
chain.reduce(instance) { |receiver, method| receiver.public_send(method) }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Parses a single literal value from the predicate RHS.
|
|
141
|
+
def parse_literal(token)
|
|
142
|
+
stripped = token.strip
|
|
143
|
+
case stripped
|
|
144
|
+
when /\A-?\d+\z/ then stripped.to_i
|
|
145
|
+
when /\A-?\d+\.\d+\z/ then stripped.to_f
|
|
146
|
+
when /\A".*"\z/, /\A'.*'\z/ then stripped[1..-2]
|
|
147
|
+
when "true" then true
|
|
148
|
+
when "false" then false
|
|
149
|
+
when "nil", "null" then nil
|
|
150
|
+
else
|
|
151
|
+
raise ConditionError, "Unsupported literal: #{token}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def compare(actual, operator, expected)
|
|
156
|
+
case operator
|
|
157
|
+
when ">=" then actual.to_f >= expected.to_f
|
|
158
|
+
when "<=" then actual.to_f <= expected.to_f
|
|
159
|
+
when "==" then actual == expected
|
|
160
|
+
when "!=" then actual != expected
|
|
161
|
+
when ">" then actual.to_f > expected.to_f
|
|
162
|
+
when "<" then actual.to_f < expected.to_f
|
|
163
|
+
else false
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module Lutaml
|
|
6
|
+
module Lml
|
|
7
|
+
class Executor
|
|
8
|
+
# CSV format adapter. Reads CSV files and maps rows to hydrated
|
|
9
|
+
# compiled-class instances using column mappings from the import
|
|
10
|
+
# definition. Exports instances back to CSV.
|
|
11
|
+
#
|
|
12
|
+
# Registered for format "csv" via FormatAdapter::BUILTIN_ADAPTERS.
|
|
13
|
+
class CsvAdapter
|
|
14
|
+
extend AdapterHelpers
|
|
15
|
+
|
|
16
|
+
# Read a CSV file and map rows to compiled-class instances.
|
|
17
|
+
# Returns an array of hydrated objects.
|
|
18
|
+
#
|
|
19
|
+
# The import definition's attributes provide column mappings:
|
|
20
|
+
# attribute name = "csv_column_header"
|
|
21
|
+
def self.import(imp, compiled:)
|
|
22
|
+
return [] unless imp.file
|
|
23
|
+
return [] unless imp.attributes&.any?
|
|
24
|
+
|
|
25
|
+
mappings = build_column_mappings(imp.attributes)
|
|
26
|
+
target_class = resolve_target_class(imp.attributes, compiled)
|
|
27
|
+
return [] unless target_class
|
|
28
|
+
|
|
29
|
+
path = imp.file
|
|
30
|
+
return [] unless File.exist?(path)
|
|
31
|
+
|
|
32
|
+
rows = CSV.read(path, headers: true)
|
|
33
|
+
rows.map { |row| build_instance(row, mappings, target_class) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Write instances to a CSV file.
|
|
37
|
+
def self.export(exp, instances, compiled:)
|
|
38
|
+
return if instances.empty?
|
|
39
|
+
|
|
40
|
+
path = attribute_value(exp.attributes, "file")
|
|
41
|
+
return unless path && !path.empty?
|
|
42
|
+
|
|
43
|
+
first_instance = instances.first
|
|
44
|
+
class_name, target_class = find_class_for_instance(first_instance, compiled)
|
|
45
|
+
return unless target_class
|
|
46
|
+
|
|
47
|
+
fields = target_class.attributes.keys
|
|
48
|
+
rows = instances.map do |inst|
|
|
49
|
+
fields.map { |f| extract_attribute_value(inst, f) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
File.write(path, generate_csv(fields, rows))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def build_column_mappings(attributes)
|
|
59
|
+
Array(attributes).each_with_object({}) do |attr, map|
|
|
60
|
+
next if attr.name == "map_to"
|
|
61
|
+
map[attr.name.to_sym] = attr.value.to_s
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_instance(row, mappings, target_class)
|
|
66
|
+
attrs = mappings.each_with_object({}) do |(attr_name, col_name), hash|
|
|
67
|
+
hash[attr_name] = row[col_name]
|
|
68
|
+
end
|
|
69
|
+
target_class.new(**attrs)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def extract_attribute_value(instance, field_name)
|
|
73
|
+
instance.public_send(field_name)
|
|
74
|
+
rescue NoMethodError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def generate_csv(headers, rows)
|
|
79
|
+
CSV.generate do |csv|
|
|
80
|
+
csv << headers
|
|
81
|
+
rows.each { |row| csv << row }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Lml
|
|
5
|
+
class Executor
|
|
6
|
+
# Pluggable format adapter registry. Adapters handle the actual
|
|
7
|
+
# I/O for a given format (CSV, XML, etc.).
|
|
8
|
+
#
|
|
9
|
+
# Each adapter must implement:
|
|
10
|
+
# .import(imp, compiled:) — read external data, return array of instances
|
|
11
|
+
# .export(exp, instances, compiled:) — write instances to external format
|
|
12
|
+
#
|
|
13
|
+
module FormatAdapter
|
|
14
|
+
class AdapterNotFoundError < StandardError; end
|
|
15
|
+
|
|
16
|
+
@adapters = {}
|
|
17
|
+
|
|
18
|
+
# Built-in adapters resolved by referencing the constant under
|
|
19
|
+
# Executor, which triggers autoload on first access. External
|
|
20
|
+
# adapters can still register via `register`.
|
|
21
|
+
BUILTIN_ADAPTERS = {
|
|
22
|
+
"csv" => :CsvAdapter,
|
|
23
|
+
"xml" => :XmlAdapter
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Register an adapter for a format name.
|
|
27
|
+
def self.register(format_name, adapter)
|
|
28
|
+
@adapters[format_name.to_s] = adapter
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Look up the adapter for a format name.
|
|
32
|
+
def self.resolve(format_name)
|
|
33
|
+
key = format_name.to_s
|
|
34
|
+
return @adapters[key] if @adapters.key?(key)
|
|
35
|
+
|
|
36
|
+
builtin = BUILTIN_ADAPTERS[key]
|
|
37
|
+
if builtin
|
|
38
|
+
adapter = Executor.const_get(builtin)
|
|
39
|
+
register(key, adapter)
|
|
40
|
+
return adapter
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
raise(AdapterNotFoundError,
|
|
44
|
+
"No adapter registered for format '#{format_name}'")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns all registered format names.
|
|
48
|
+
def self.registered_formats
|
|
49
|
+
@adapters.keys
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "moxml"
|
|
4
|
+
|
|
5
|
+
module Lutaml
|
|
6
|
+
module Lml
|
|
7
|
+
class Executor
|
|
8
|
+
# XML format adapter. Reads XML files and maps elements to hydrated
|
|
9
|
+
# compiled-class instances, and writes instances back to XML.
|
|
10
|
+
#
|
|
11
|
+
# Registered for format "xml" via FormatAdapter::BUILTIN_ADAPTERS.
|
|
12
|
+
#
|
|
13
|
+
# Element-to-attribute mapping is provided by the lutaml-model XML
|
|
14
|
+
# mapping generated on each compiled class by ModelCompiler. This
|
|
15
|
+
# adapter only selects which XML elements correspond to records
|
|
16
|
+
# (via the `where` XPath from the import definition) and hands
|
|
17
|
+
# each one to `target_class.from_xml` for deserialization.
|
|
18
|
+
#
|
|
19
|
+
# Import shape (from LML `import { xml "..." { map_to X; where "/y" } }`):
|
|
20
|
+
# - imp.file = path to XML file
|
|
21
|
+
# - imp.attributes = TopElementAttribute list including:
|
|
22
|
+
# map_to: TargetClass (compiled-class key)
|
|
23
|
+
# where: XPath selector for each record element
|
|
24
|
+
#
|
|
25
|
+
# Export shape (from LML `export { format xml { file "..."; root "X" } }`):
|
|
26
|
+
# - exp.attributes = TopElementAttribute list including:
|
|
27
|
+
# file: output path
|
|
28
|
+
# root: root tag name (defaults to compiled class name)
|
|
29
|
+
# indent: "true" / "false" (default true)
|
|
30
|
+
# encoding: encoding string (default UTF-8)
|
|
31
|
+
class XmlAdapter
|
|
32
|
+
extend AdapterHelpers
|
|
33
|
+
|
|
34
|
+
DEFAULT_ENCODING = "UTF-8"
|
|
35
|
+
DEFAULT_SELECTOR = "/*/*"
|
|
36
|
+
DEFAULT_ROOT_SUFFIX = "s"
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Read an XML file and map elements to compiled-class instances.
|
|
40
|
+
# Returns an array of hydrated objects.
|
|
41
|
+
def import(imp, compiled:)
|
|
42
|
+
return [] unless imp.file
|
|
43
|
+
return [] unless imp.attributes&.any?
|
|
44
|
+
|
|
45
|
+
target_class = resolve_target_class(imp.attributes, compiled)
|
|
46
|
+
return [] unless target_class
|
|
47
|
+
|
|
48
|
+
path = imp.file
|
|
49
|
+
return [] unless File.exist?(path)
|
|
50
|
+
|
|
51
|
+
doc = Moxml.parse(File.read(path))
|
|
52
|
+
selector = attribute_value(imp.attributes, "where") || DEFAULT_SELECTOR
|
|
53
|
+
|
|
54
|
+
doc.xpath(selector).map do |element|
|
|
55
|
+
target_class.from_xml(element.to_xml)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Write instances to an XML file.
|
|
60
|
+
def export(exp, instances, compiled:)
|
|
61
|
+
return if instances.empty?
|
|
62
|
+
|
|
63
|
+
path = attribute_value(exp.attributes, "file")
|
|
64
|
+
return unless path && !path.empty?
|
|
65
|
+
|
|
66
|
+
class_name, target_class = find_class_for_instance(instances.first, compiled)
|
|
67
|
+
return unless target_class
|
|
68
|
+
|
|
69
|
+
options = export_options(exp, class_name)
|
|
70
|
+
File.write(path, build_export_xml(instances, options))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def export_options(exp, class_name)
|
|
76
|
+
{
|
|
77
|
+
root: attribute_value(exp.attributes, "root") || "#{class_name}#{DEFAULT_ROOT_SUFFIX}",
|
|
78
|
+
indent: attribute_value(exp.attributes, "indent") != "false",
|
|
79
|
+
encoding: attribute_value(exp.attributes, "encoding") || DEFAULT_ENCODING
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Builds the export XML by parsing each instance's `to_xml`
|
|
84
|
+
# output, then grafting its root element under a single
|
|
85
|
+
# configurable root. lutaml-model handles per-record
|
|
86
|
+
# serialization via the generated XML mapping.
|
|
87
|
+
def build_export_xml(instances, options)
|
|
88
|
+
doc = Moxml.parse("<#{options[:root]}/>")
|
|
89
|
+
root = doc.root
|
|
90
|
+
|
|
91
|
+
instances.each do |inst|
|
|
92
|
+
record_doc = Moxml.parse(inst.to_xml)
|
|
93
|
+
root.add_child(record_doc.root)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
doc.to_xml(indent: options[:indent] ? 2 : 0)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Lml
|
|
5
|
+
# Orchestrates instance data I/O: import external data, validate
|
|
6
|
+
# collections, and export to external formats.
|
|
7
|
+
#
|
|
8
|
+
# Format-specific I/O is delegated to registered adapters via the
|
|
9
|
+
# FormatAdapter registry. The core executor only handles the
|
|
10
|
+
# orchestration — it knows *what* to do, adapters know *how*.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# compiled = ModelCompiler.new.compile(models_file)
|
|
14
|
+
# doc = Pipeline.call(instances_file, resolve: false)
|
|
15
|
+
# instances = Executor.run(doc, compiled: compiled)
|
|
16
|
+
#
|
|
17
|
+
class Executor
|
|
18
|
+
autoload :FormatAdapter, "lutaml/lml/executor/format_adapter"
|
|
19
|
+
autoload :AdapterHelpers, "lutaml/lml/executor/adapter_helpers"
|
|
20
|
+
autoload :CsvAdapter, "lutaml/lml/executor/csv_adapter"
|
|
21
|
+
autoload :XmlAdapter, "lutaml/lml/executor/xml_adapter"
|
|
22
|
+
autoload :ConditionEvaluator, "lutaml/lml/executor/condition_evaluator"
|
|
23
|
+
|
|
24
|
+
attr_reader :compiled
|
|
25
|
+
|
|
26
|
+
def initialize(compiled:)
|
|
27
|
+
@compiled = compiled
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Run the full import/validate/export cycle on a parsed document.
|
|
31
|
+
# Returns an array of hydrated instance objects.
|
|
32
|
+
def self.run(doc, compiled:)
|
|
33
|
+
new(compiled: compiled).run(doc)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run(doc)
|
|
37
|
+
instances = run_imports(doc)
|
|
38
|
+
validate_collections(doc, instances)
|
|
39
|
+
run_exports(doc, instances)
|
|
40
|
+
instances
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# --- Import ---
|
|
46
|
+
|
|
47
|
+
def run_imports(doc)
|
|
48
|
+
return [] unless doc.instances&.imports&.any?
|
|
49
|
+
|
|
50
|
+
doc.instances.imports.flat_map { |imp| import_one(imp) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def import_one(imp)
|
|
54
|
+
adapter = FormatAdapter.resolve(imp.format_type)
|
|
55
|
+
adapter.import(imp, compiled: @compiled)
|
|
56
|
+
rescue FormatAdapter::AdapterNotFoundError
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# --- Collection validation ---
|
|
61
|
+
|
|
62
|
+
def validate_collections(doc, instances)
|
|
63
|
+
return unless doc.instances&.collections
|
|
64
|
+
|
|
65
|
+
collection = doc.instances.collections
|
|
66
|
+
return unless collection.is_a?(Collection)
|
|
67
|
+
|
|
68
|
+
ConditionEvaluator.evaluate(collection, instances)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# --- Export ---
|
|
72
|
+
|
|
73
|
+
def run_exports(doc, instances)
|
|
74
|
+
return unless doc.instances&.exports&.any?
|
|
75
|
+
|
|
76
|
+
doc.instances.exports.each do |exp|
|
|
77
|
+
export_one(exp, instances)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def export_one(exp, instances)
|
|
82
|
+
adapter = FormatAdapter.resolve(exp.format_type)
|
|
83
|
+
adapter.export(exp, instances, compiled: @compiled)
|
|
84
|
+
rescue FormatAdapter::AdapterNotFoundError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|