lutaml-model 0.8.14 → 0.8.16

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +14 -73
  3. data/Gemfile +3 -5
  4. data/README.adoc +188 -25
  5. data/docs/_guides/xml-mapping.adoc +178 -24
  6. data/docs/_pages/importable_models.adoc +7 -1
  7. data/lib/lutaml/jsonld/transform.rb +44 -13
  8. data/lib/lutaml/model/attribute.rb +4 -0
  9. data/lib/lutaml/model/liquefiable.rb +9 -0
  10. data/lib/lutaml/model/liquid/indexed_access.rb +33 -0
  11. data/lib/lutaml/model/liquid.rb +1 -0
  12. data/lib/lutaml/model/version.rb +1 -1
  13. data/lib/lutaml/model.rb +2 -1
  14. data/lib/lutaml/rdf/mapping.rb +10 -1
  15. data/lib/lutaml/rdf/member_rule.rb +29 -4
  16. data/lib/lutaml/turtle/transform.rb +55 -35
  17. data/lib/lutaml/xml/adapter/plan_based_builder.rb +3 -1
  18. data/lib/lutaml/xml/adapter/xml_serializer.rb +15 -5
  19. data/lib/lutaml/xml/adapter.rb +2 -1
  20. data/lib/lutaml/xml/builder/base.rb +2 -1
  21. data/lib/lutaml/xml/data_model.rb +19 -3
  22. data/lib/lutaml/xml/mapping.rb +3 -1
  23. data/lib/lutaml/xml/mapping_rule.rb +28 -2
  24. data/lib/lutaml/xml/model_transform.rb +9 -1
  25. data/lib/lutaml/xml/serialization/instance_methods.rb +16 -9
  26. data/lib/lutaml/xml/transformation/element_builder.rb +1 -3
  27. data/lib/lutaml/xml/transformation/rule_applier.rb +21 -0
  28. data/lib/lutaml/xml/transformation/rule_compiler.rb +12 -3
  29. data/lutaml-model.gemspec +1 -1
  30. data/spec/lutaml/jsonld/transform_spec.rb +149 -0
  31. data/spec/lutaml/model/liquid/indexed_access_spec.rb +135 -0
  32. data/spec/lutaml/model/mixed_content_spec.rb +48 -7
  33. data/spec/lutaml/model/raw_element_spec.rb +533 -0
  34. data/spec/lutaml/rdf/mapping_spec.rb +71 -6
  35. data/spec/lutaml/rdf/member_rule_spec.rb +103 -1
  36. data/spec/lutaml/turtle/transform_spec.rb +144 -0
  37. metadata +9 -6
@@ -1097,42 +1097,184 @@ end
1097
1097
  ====
1098
1098
 
1099
1099
 
1100
- [[xml-map-all]]
1101
- ==== Mapping entire XML element into an attribute
1102
1100
 
1103
- The `map_all` tag in XML mapping captures and maps all content within an XML
1104
- element into a single attribute in the target Ruby object.
1101
+ [[xml-raw-element]]
1102
+ ==== Capturing raw XML content
1105
1103
 
1106
- The use case for `map_all` is to tell Lutaml::Model to not parse the content of
1107
- the XML element at all, and instead handle it as an XML string.
1104
+ Lutaml::Model provides several mechanisms for capturing XML content as raw strings.
1105
+ Use these when you need to embed foreign XML vocabularies (SVG, MathML, XSL-FO) or
1106
+ preserve markup that your model doesn't need to parse.
1108
1107
 
1109
- NOTE: The corresponding method for key-value formats is at <<key-value-map-all>>.
1108
+ ===== `raw: :element` on `map_element` -- full element capture (recommended)
1110
1109
 
1111
- WARNING: Notice that usage of mapping all will lead to incompatibility between
1112
- serialization formats, i.e. the raw string content will not be portable as
1113
- objects are across different formats.
1110
+ The `raw: :element` option captures a specific mapped element as a complete XML
1111
+ string, including its opening tag, attributes, children, and closing tag.
1112
+ This provides **lossless round-trip** fidelity -- the captured string is
1113
+ self-contained and serialized verbatim.
1114
1114
 
1115
- This is useful in the case where the content of an XML element is not to be
1116
- handled by a Lutaml::Model::Serializable object.
1115
+ .Syntax
1116
+ [source,ruby]
1117
+ ----
1118
+ xml do
1119
+ map_element "svg", to: :svg_data, raw: :element
1120
+ end
1121
+ ----
1117
1122
 
1118
- This feature is commonly used with custom methods or a custom model object to
1119
- handle the content.
1123
+ .Capturing an SVG element as raw XML
1124
+ [example]
1125
+ ====
1126
+ [source,ruby]
1127
+ ----
1128
+ class SvgContainer < Lutaml::Model::Serializable
1129
+ attribute :name, :string
1130
+ attribute :svg_data, :string
1120
1131
 
1121
- This includes:
1132
+ xml do
1133
+ element "container"
1134
+ map_element "name", to: :name
1135
+ map_element "svg", to: :svg_data, raw: :element
1136
+ end
1137
+ end
1138
+ ----
1122
1139
 
1123
- * nested tags
1124
- * attributes
1125
- * text nodes
1140
+ [source,xml]
1141
+ ----
1142
+ <container>
1143
+ <name>diagram</name>
1144
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
1145
+ <rect x="0" y="0" width="100" height="100" fill="red"/>
1146
+ </svg>
1147
+ </container>
1148
+ ----
1126
1149
 
1127
- The `map_all` tag is **exclusive** and cannot be combined with other mappings
1128
- (`map_element`, `map_content`) except for `map_attribute` for the same element,
1129
- ensuring it captures the entire inner XML content.
1150
+ [source,ruby]
1151
+ ----
1152
+ > doc = SvgContainer.from_xml(xml)
1153
+ > doc.name
1154
+ # => "diagram"
1155
+ > doc.svg_data
1156
+ # => "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\">\n <rect x=\"0\" y=\"0\" width=\"100\" height=\"100\" fill=\"red\"/>\n</svg>"
1157
+ ----
1130
1158
 
1131
- NOTE: An error is raised if `map_all` is defined alongside any other mapping in
1132
- the same XML mapping context.
1159
+ The captured string round-trips correctly -- serializing the model back to XML
1160
+ outputs the raw element verbatim without escaping or wrapping.
1133
1161
 
1134
- Syntax:
1162
+ `raw: :element` also works with collection attributes:
1135
1163
 
1164
+ [source,ruby]
1165
+ ----
1166
+ class MultiFragment < Lutaml::Model::Serializable
1167
+ attribute :fragments, :string, collection: true
1168
+
1169
+ xml do
1170
+ element "container"
1171
+ map_element "fragment", to: :fragments, raw: :element
1172
+ end
1173
+ end
1174
+ ----
1175
+ ====
1176
+
1177
+ ===== `raw: :content` on `map_element` -- inner content capture
1178
+
1179
+ The `raw: :content` option captures only the *inner XML* of the matched element,
1180
+ without the wrapper tags. The wrapper element is reconstructed from the mapping rule
1181
+ during serialization.
1182
+
1183
+ .Syntax
1184
+ [source,ruby]
1185
+ ----
1186
+ xml do
1187
+ map_element "street", to: :street, raw: :content
1188
+ end
1189
+ ----
1190
+
1191
+ .Capturing inner XML content
1192
+ [example]
1193
+ ====
1194
+ [source,ruby]
1195
+ ----
1196
+ class Address < Lutaml::Model::Serializable
1197
+ attribute :street, :string
1198
+
1199
+ xml do
1200
+ element "address"
1201
+ map_element "street", to: :street, raw: :content
1202
+ end
1203
+ end
1204
+ ----
1205
+
1206
+ [source,xml]
1207
+ ----
1208
+ <address><street><b>123</b> Main St</street></address>
1209
+ ----
1210
+
1211
+ [source,ruby]
1212
+ ----
1213
+ > doc = Address.from_xml(xml)
1214
+ > doc.street
1215
+ # => "<b>123</b> Main St"
1216
+ ----
1217
+ ====
1218
+
1219
+ NOTE: `raw: :content` is **lossy** -- namespace prefixes declared on the wrapper
1220
+ element are not captured. For lossless capture, use `raw: :element` instead.
1221
+
1222
+ ===== Namespace behavior
1223
+
1224
+ Both `raw: :element` and `raw: :content` match elements by local name regardless
1225
+ of namespace or prefix:
1226
+
1227
+ * Elements with no namespace (`<svg>`)
1228
+ * Elements with an `xmlns` declaration on themselves (`<svg xmlns="...">`)
1229
+ * Elements inheriting a default namespace from a parent
1230
+ * Elements with an explicit namespace prefix (`<ns:svg>`)
1231
+
1232
+ This is intentional -- raw capture is designed to handle foreign XML vocabularies
1233
+ without needing model classes for them.
1234
+
1235
+ ===== Round-trip fidelity comparison
1236
+
1237
+ | Aspect | `raw: :element` | `raw: :content` |
1238
+ |--------|----------------|-----------------|
1239
+ | Element name | Preserved (in string) | Reconstructed from rule |
1240
+ | Element attributes | Preserved (in string) | Lost unless separately mapped |
1241
+ | Inner content | Preserved (in string) | Preserved (in string) |
1242
+ | Namespace prefixes | Safe (declared in string) | May break if declared on wrapper |
1243
+ | Fidelity | **Lossless** | **Lossy** |
1244
+
1245
+ ===== Wrapper model pattern for attribute extraction
1246
+
1247
+ Raw capture stores the element as a string. If you need to inspect or modify
1248
+ attributes on the captured element, use a wrapper model with `map_all`:
1249
+
1250
+ [source,ruby]
1251
+ ----
1252
+ class SvgInner < Lutaml::Model::Serializable
1253
+ attribute :raw, :string
1254
+
1255
+ xml do
1256
+ element "svg"
1257
+ map_all to: :raw
1258
+ end
1259
+ end
1260
+
1261
+ class Container < Lutaml::Model::Serializable
1262
+ attribute :svg_data, SvgInner
1263
+
1264
+ xml do
1265
+ element "container"
1266
+ map_element "svg", to: :svg_data
1267
+ end
1268
+ end
1269
+ ----
1270
+
1271
+ ===== `map_all` -- root inner XML capture
1272
+
1273
+ The `map_all` directive captures the entire inner XML of the root element into
1274
+ a single attribute. It is **exclusive** -- it cannot be combined with other
1275
+ `map_element` or `map_content` mappings (only `map_attribute` is allowed).
1276
+
1277
+ .Syntax
1136
1278
  [source,ruby]
1137
1279
  ----
1138
1280
  xml do
@@ -1167,6 +1309,18 @@ end
1167
1309
  ----
1168
1310
  ====
1169
1311
 
1312
+ ===== Deprecated: `attribute :x, :string, raw: true`
1313
+
1314
+ [WARNING]
1315
+ ====
1316
+ `attribute :name, :string, raw: true` is deprecated. Use
1317
+ `map_element "name", to: :name, raw: :content` instead.
1318
+ ====
1319
+
1320
+ This legacy syntax captures the inner XML of the matched element.
1321
+ It is equivalent to `raw: :content` on the mapping. Existing code continues
1322
+ to work but emits a deprecation warning.
1323
+
1170
1324
 
1171
1325
  ==== Mapping CDATA nodes
1172
1326
 
@@ -546,7 +546,13 @@ the current value is the same as the default value.
546
546
 
547
547
 
548
548
 
549
- === Attribute as raw string
549
+ === Attribute as raw string (deprecated)
550
+
551
+ [WARNING]
552
+ ====
553
+ `attribute :name, :string, raw: true` is deprecated.
554
+ Use `map_element "name", to: :name, raw: :content` instead.
555
+ ====
550
556
 
551
557
  An attribute can be set to read the value as raw string for XML, by using the `raw: true` option.
552
558
 
@@ -45,6 +45,12 @@ module Lutaml
45
45
  build_instance(attrs, options)
46
46
  end
47
47
 
48
+ protected
49
+
50
+ def additional_resource_data(_instance, _mapping)
51
+ {}
52
+ end
53
+
48
54
  private
49
55
 
50
56
  def extract_mapping(options)
@@ -52,28 +58,32 @@ module Lutaml
52
58
  end
53
59
 
54
60
  def build_graph_document(mapping, instance)
55
- context = build_merged_context(mapping, instance)
61
+ context = build_merged_context_recursive(mapping, instance)
62
+ graph = collect_resources(mapping, instance)
63
+
64
+ { "@context" => context, "@graph" => graph }
65
+ end
66
+
67
+ def collect_resources(mapping, instance)
56
68
  graph = []
57
69
 
58
- if mapping.rdf_subject
59
- resource = build_resource_data(mapping, instance)
60
- graph << resource unless resource.empty?
61
- end
70
+ resource = build_resource_data(mapping, instance)
71
+ graph << resource unless resource.empty?
62
72
 
63
73
  mapping.rdf_members.each do |member_rule|
64
74
  each_member(instance, member_rule) do |member|
65
75
  member_mapping = member_mapping_for(member, :jsonld)
66
76
  next unless member_mapping
67
77
 
68
- resource = build_resource_data(member_mapping, member)
69
- graph << resource unless resource.empty?
78
+ child_resources = collect_resources(member_mapping, member)
79
+ graph.concat(child_resources)
70
80
  end
71
81
  end
72
82
 
73
- { "@context" => context, "@graph" => graph }
83
+ graph
74
84
  end
75
85
 
76
- def build_merged_context(mapping, instance)
86
+ def build_merged_context_recursive(mapping, instance)
77
87
  context_hash = build_context_from_mapping(mapping).to_hash
78
88
 
79
89
  mapping.rdf_members.each do |member_rule|
@@ -82,6 +92,9 @@ module Lutaml
82
92
  next unless member_mapping
83
93
 
84
94
  context_hash.merge!(build_context_from_mapping(member_mapping).to_hash)
95
+
96
+ child_ctx = build_merged_context_recursive(member_mapping, member)
97
+ context_hash.merge!(child_ctx)
85
98
  end
86
99
  end
87
100
 
@@ -100,9 +113,14 @@ module Lutaml
100
113
  mapping.rdf_members.each do |member_rule|
101
114
  next unless member_rule.linked?
102
115
 
103
- context.term(member_rule.predicate_name.to_s,
104
- id: member_rule.linked_predicate_uri,
105
- type: "@id")
116
+ predicate_uri = if member_rule.predicate_name
117
+ member_rule.linked_predicate_uri
118
+ end
119
+ if predicate_uri
120
+ context.term(member_rule.predicate_name.to_s,
121
+ id: predicate_uri,
122
+ type: "@id")
123
+ end
106
124
  end
107
125
 
108
126
  context
@@ -151,9 +169,12 @@ module Lutaml
151
169
  member_refs = collect_member_references(instance, member_rule)
152
170
  next if member_refs.empty?
153
171
 
154
- result[member_rule.predicate_name.to_s] = member_refs
172
+ key = jsonld_member_key(member_rule)
173
+ result[key] = member_refs
155
174
  end
156
175
 
176
+ result.merge!(additional_resource_data(instance, mapping))
177
+
157
178
  result
158
179
  end
159
180
 
@@ -168,6 +189,16 @@ module Lutaml
168
189
  refs
169
190
  end
170
191
 
192
+ def jsonld_member_key(member_rule)
193
+ if member_rule.predicate_name
194
+ member_rule.predicate_name.to_s
195
+ elsif member_rule.link.is_a?(String)
196
+ member_rule.link.split(":").last
197
+ else
198
+ member_rule.attr_name.to_s
199
+ end
200
+ end
201
+
171
202
  def serialize_value(value, rule)
172
203
  case rule.kind
173
204
  when :uri_reference then serialize_uri_reference(value)
@@ -665,6 +665,10 @@ instance_object = nil)
665
665
  def process_options!
666
666
  validate_options!(@options)
667
667
  @raw = !!@options[:raw]
668
+ if @raw
669
+ warn "[DEPRECATED] attribute :#{name}, :string, raw: true is deprecated. " \
670
+ "Use map_element \"name\", to: :#{name}, raw: :content instead."
671
+ end
668
672
  @validations = @options[:validations]
669
673
  set_default_for_collection if collection?
670
674
  end
@@ -54,6 +54,15 @@ module Lutaml
54
54
  value.to_liquid
55
55
  end
56
56
  end
57
+
58
+ def liquid_method_missing(method)
59
+ if @object.is_a?(::Lutaml::Model::Liquid::IndexedAccess)
60
+ result = @object.liquid_fetch(method)
61
+ result.nil? ? super : liquefy_value(result)
62
+ else
63
+ super
64
+ end
65
+ end
57
66
  end)
58
67
  end
59
68
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module Liquid
6
+ # Module for Lutaml::Model objects that support bracket-based
7
+ # lookup in Liquid templates (e.g., collections with index or key access).
8
+ #
9
+ # Include this module in any Serializable subclass that supports
10
+ # +self[key]+ so that its auto-generated Liquid drop can delegate
11
+ # +drop[key]+ through to the underlying object.
12
+ #
13
+ # Example:
14
+ # class Glossarist::Collections::LocalizationCollection
15
+ # include Lutaml::Model::Liquid::IndexedAccess
16
+ # # ...
17
+ # end
18
+ #
19
+ # drop['eng'] #=> calls drop.liquid_method_missing('eng')
20
+ # #=> calls @object.liquid_fetch('eng')
21
+ # #=> calls @object['eng']
22
+ # #=> returns the localized concept drop
23
+ module IndexedAccess
24
+ # Called by the auto-generated Liquid drop via +liquid_method_missing+.
25
+ # Delegates to +self[key]+ by default. Override in specific classes
26
+ # for custom lookup behavior.
27
+ def liquid_fetch(key)
28
+ self[key]
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -3,6 +3,7 @@
3
3
  module Lutaml
4
4
  module Model
5
5
  module Liquid
6
+ autoload :IndexedAccess, "#{__dir__}/liquid/indexed_access"
6
7
  autoload :Mapping, "#{__dir__}/liquid/mapping"
7
8
  end
8
9
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.14"
5
+ VERSION = "0.8.16"
6
6
  end
7
7
  end
data/lib/lutaml/model.rb CHANGED
@@ -149,7 +149,8 @@ module Lutaml
149
149
  "#{__dir__}/model/error/incorrect_sequence_error"
150
150
  autoload :ChoiceUpperBoundError,
151
151
  "#{__dir__}/model/error/choice_upper_bound_error"
152
- autoload :TypeOnlyMappingError, "#{__dir__}/model/error/type_only_mapping_error"
152
+ autoload :TypeOnlyMappingError,
153
+ "#{__dir__}/model/error/type_only_mapping_error"
153
154
  autoload :NoRootMappingError, "#{__dir__}/model/error/no_root_mapping_error"
154
155
  autoload :ImportModelWithRootError,
155
156
  "#{__dir__}/model/error/import_model_with_root_error"
@@ -27,6 +27,14 @@ module Lutaml
27
27
  @rdf_type = Array(value)
28
28
  end
29
29
 
30
+ def types(*values)
31
+ @rdf_type = values.flatten
32
+ end
33
+
34
+ def has_types_or_predicates?
35
+ @rdf_type.any? || @rdf_predicates.any?
36
+ end
37
+
30
38
  def predicate(name, namespace:, to:, lang_tagged: false,
31
39
  uri_reference: false)
32
40
  @rdf_predicates << Lutaml::Rdf::MappingRule.new(
@@ -38,11 +46,12 @@ module Lutaml
38
46
  )
39
47
  end
40
48
 
41
- def members(attr_name, predicate_name: nil, namespace: nil)
49
+ def members(attr_name, predicate_name: nil, namespace: nil, link: nil)
42
50
  @rdf_members << Lutaml::Rdf::MemberRule.new(
43
51
  attr_name,
44
52
  predicate_name: predicate_name,
45
53
  namespace: namespace,
54
+ link: link,
46
55
  )
47
56
  end
48
57
 
@@ -3,28 +3,53 @@
3
3
  module Lutaml
4
4
  module Rdf
5
5
  class MemberRule
6
- attr_reader :attr_name, :predicate_name, :namespace
6
+ attr_reader :attr_name, :predicate_name, :namespace, :link
7
7
 
8
- def initialize(attr_name, predicate_name: nil, namespace: nil)
8
+ def initialize(attr_name, predicate_name: nil, namespace: nil, link: nil)
9
9
  if predicate_name && !namespace
10
10
  raise ArgumentError,
11
11
  "namespace is required when predicate_name is provided"
12
12
  end
13
13
 
14
+ if predicate_name && link
15
+ raise ArgumentError,
16
+ "predicate_name and link are mutually exclusive"
17
+ end
18
+
14
19
  @attr_name = attr_name.to_sym
15
20
  @predicate_name = predicate_name
16
21
  @namespace = namespace
22
+ @link = link
17
23
  end
18
24
 
19
25
  def linked?
20
- !!@predicate_name
26
+ !!(@predicate_name || @link)
21
27
  end
22
28
 
23
29
  def linked_predicate_uri
24
- return nil unless linked?
30
+ return nil unless @predicate_name
25
31
 
26
32
  @namespace[@predicate_name]
27
33
  end
34
+
35
+ def link_predicate_for(member, resolver)
36
+ return nil unless @link
37
+
38
+ case @link
39
+ when String
40
+ resolver.call(@link)
41
+ when Proc
42
+ resolver.call(@link.call(member))
43
+ end
44
+ end
45
+
46
+ def resolve_link_uri(member, resolver)
47
+ if @predicate_name
48
+ linked_predicate_uri
49
+ elsif @link
50
+ link_predicate_for(member, resolver)
51
+ end
52
+ end
28
53
  end
29
54
  end
30
55
  end