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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/lib/lutaml/lml/association_label_resolver.rb +52 -0
  3. data/lib/lutaml/lml/cli.rb +262 -0
  4. data/lib/lutaml/lml/data_processor/attribute_processing.rb +81 -0
  5. data/lib/lutaml/lml/data_processor/collection_processing.rb +37 -0
  6. data/lib/lutaml/lml/data_processor/instance_processing.rb +63 -0
  7. data/lib/lutaml/lml/data_processor/value_processing.rb +98 -0
  8. data/lib/lutaml/lml/data_processor/view_processing.rb +25 -0
  9. data/lib/lutaml/lml/data_processor.rb +49 -0
  10. data/lib/lutaml/lml/document_builder.rb +139 -0
  11. data/lib/lutaml/lml/executor/adapter_helpers.rb +45 -0
  12. data/lib/lutaml/lml/executor/condition_evaluator.rb +169 -0
  13. data/lib/lutaml/lml/executor/csv_adapter.rb +88 -0
  14. data/lib/lutaml/lml/executor/format_adapter.rb +54 -0
  15. data/lib/lutaml/lml/executor/xml_adapter.rb +102 -0
  16. data/lib/lutaml/lml/executor.rb +89 -0
  17. data/lib/lutaml/lml/format/adapter/document.rb +11 -0
  18. data/lib/lutaml/lml/format/adapter/mapping.rb +19 -0
  19. data/lib/lutaml/lml/format/adapter/standard_adapter.rb +127 -0
  20. data/lib/lutaml/lml/format/adapter/transform.rb +11 -0
  21. data/lib/lutaml/lml/format.rb +29 -0
  22. data/lib/lutaml/lml/formatter/base.rb +79 -0
  23. data/lib/lutaml/lml/formatter/graphviz/document_formatter.rb +89 -0
  24. data/lib/lutaml/lml/formatter/graphviz/html_builder.rb +72 -0
  25. data/lib/lutaml/lml/formatter/graphviz/node_formatter.rb +74 -0
  26. data/lib/lutaml/lml/formatter/graphviz/relationship_formatter.rb +130 -0
  27. data/lib/lutaml/lml/formatter/graphviz.rb +90 -0
  28. data/lib/lutaml/lml/formatter.rb +8 -0
  29. data/lib/lutaml/lml/grammar/concerns/associations.rb +76 -0
  30. data/lib/lutaml/lml/grammar/concerns/attributes.rb +126 -0
  31. data/lib/lutaml/lml/grammar/concerns/data_structures.rb +84 -0
  32. data/lib/lutaml/lml/grammar/concerns/definitions.rb +222 -0
  33. data/lib/lutaml/lml/grammar/concerns/instance_rules.rb +59 -0
  34. data/lib/lutaml/lml/grammar/concerns/primitives.rb +89 -0
  35. data/lib/lutaml/lml/grammar/concerns/view_rules.rb +34 -0
  36. data/lib/lutaml/lml/grammar/concerns.rb +17 -0
  37. data/lib/lutaml/lml/grammar/core.rb +71 -0
  38. data/lib/lutaml/lml/grammar/full.rb +12 -0
  39. data/lib/lutaml/lml/grammar/instances.rb +38 -0
  40. data/lib/lutaml/lml/grammar.rb +12 -0
  41. data/lib/lutaml/lml/has_attributes.rb +14 -0
  42. data/lib/lutaml/lml/import_resolver.rb +89 -0
  43. data/lib/lutaml/lml/layout/engine.rb +17 -0
  44. data/lib/lutaml/lml/layout/graph_viz_engine.rb +19 -0
  45. data/lib/lutaml/lml/layout.rb +8 -0
  46. data/lib/lutaml/lml/model_compiler.rb +325 -0
  47. data/lib/lutaml/lml/models/action.rb +10 -0
  48. data/lib/lutaml/lml/models/association.rb +26 -0
  49. data/lib/lutaml/lml/models/cardinality.rb +10 -0
  50. data/lib/lutaml/lml/models/collection.rb +11 -0
  51. data/lib/lutaml/lml/models/constraint.rb +20 -0
  52. data/lib/lutaml/lml/models/data_type.rb +31 -0
  53. data/lib/lutaml/lml/models/diagram.rb +17 -0
  54. data/lib/lutaml/lml/models/document.rb +45 -0
  55. data/lib/lutaml/lml/models/enum.rb +28 -0
  56. data/lib/lutaml/lml/models/fidelity.rb +10 -0
  57. data/lib/lutaml/lml/models/group.rb +11 -0
  58. data/lib/lutaml/lml/models/instance.rb +13 -0
  59. data/lib/lutaml/lml/models/instance_collection.rb +12 -0
  60. data/lib/lutaml/lml/models/instances_export.rb +10 -0
  61. data/lib/lutaml/lml/models/instances_import.rb +11 -0
  62. data/lib/lutaml/lml/models/operation.rb +23 -0
  63. data/lib/lutaml/lml/models/operation_parameter.rb +11 -0
  64. data/lib/lutaml/lml/models/package.rb +22 -0
  65. data/lib/lutaml/lml/models/primitive_type.rb +26 -0
  66. data/lib/lutaml/lml/models/top_element_attribute.rb +31 -0
  67. data/lib/lutaml/lml/models/uml_class.rb +84 -0
  68. data/lib/lutaml/lml/models/value.rb +12 -0
  69. data/lib/lutaml/lml/models/view_filter.rb +11 -0
  70. data/lib/lutaml/lml/models/view_import.rb +11 -0
  71. data/lib/lutaml/lml/parser.rb +22 -0
  72. data/lib/lutaml/lml/pipeline.rb +64 -0
  73. data/lib/lutaml/lml/preprocessor.rb +56 -0
  74. data/lib/lutaml/lml/transform.rb +20 -0
  75. data/lib/lutaml/lml/version.rb +7 -0
  76. data/lib/lutaml/lml/view_resolver.rb +48 -0
  77. data/lib/lutaml/lml/yaml_parser.rb +17 -0
  78. data/lib/lutaml/lml.rb +67 -0
  79. 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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ module Format
6
+ module Adapter
7
+ class Document < Lutaml::KeyValue::Document; end
8
+ end
9
+ end
10
+ end
11
+ end