lutaml-xsd 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da358b1ca55e63cb442380477680417cdff4627d23f83732a787713fdd1979bf
4
- data.tar.gz: d717c30574106c682a448f390c5db3f8708e0132f56462f2cfd0789255d7b222
3
+ metadata.gz: 29e9ba5ed3edf116b76cdf22ed853a37399d00ffda6b11f78a0cccdb5502b4e4
4
+ data.tar.gz: 6213f84485cad0ffed1eef19d60ae29fea7d5e274522ba8dfcc996048694e677
5
5
  SHA512:
6
- metadata.gz: 14debf574d626ee36acdbb03377abe472918989252a09aec7a56434cf6d138b5996a88b087c25347fc3383e59b7bd27367ae954be7d15891ba26c1ae7f10729b
7
- data.tar.gz: 26e1958238b0f1f973f57429e7c0a1b86c56fddd214c813f8be1e1b00dd8c38b3d8e24e3d887e848fba77658e7591e95ad1a731a3b2b55e78f12fa0c59fcc7aa
6
+ metadata.gz: 07a2c5d774d76218e043372749950daa031c3b1ff3177a4eb65c5a05de6e6747479bbbd4acd7a5c7a808553bb75e62808d66bb68157324b4341d25045e11aa0b
7
+ data.tar.gz: cba0111db7b0b7bf12fb9e0c55edbdfcbc4e3f4f11a653957ad663e6d8c067a1980bcbb7a54898f50b6e408869ca56d0179a822a7fcae8ba201111c68fceeb15
@@ -35,6 +35,12 @@ module Lutaml
35
35
  class SchemaSerializer
36
36
  include ::Lutaml::Xsd::Spa::Utils::ExtractEnumeration
37
37
 
38
+ # Fields to merge when combining schemas with the same targetNamespace
39
+ MERGEABLE_CONTENT_FIELDS = %i[
40
+ elements complex_types simple_types
41
+ attributes groups attribute_groups
42
+ ].freeze
43
+
38
44
  attr_reader :repository, :config, :package
39
45
 
40
46
  # Initialize schema serializer
@@ -217,12 +223,104 @@ module Lutaml
217
223
  serialize_schema(schema, index, file_path)
218
224
  end.compact
219
225
 
226
+ # Merge schemas that share the same target namespace (from <include>)
227
+ # XSD <include> merges included schemas into the same namespace.
228
+ # Group by namespace, keep entry point as primary, merge content from others.
229
+ schemas_data = merge_included_schemas(schemas_data)
230
+
220
231
  # Post-process: add used_by reverse references
221
232
  attach_used_by_references(schemas_data)
222
233
 
223
234
  schemas_data
224
235
  end
225
236
 
237
+ # Merge schemas sharing the same targetNamespace (from <include> directives)
238
+ #
239
+ # In XSD, <include> means the included schema targets the same namespace.
240
+ # These should appear as a single merged schema in the SPA, not as
241
+ # separate empty + populated entries.
242
+ #
243
+ # Schemas with nil namespace (chameleon schemas) are NOT merged since
244
+ # they adopt the namespace of their including schema at parse time.
245
+ #
246
+ # @param schemas_data [Array<Hash>] Serialized schema data
247
+ # @return [Array<Hash>] Merged schema data
248
+ def merge_included_schemas(schemas_data)
249
+ return schemas_data if schemas_data.length <= 1
250
+
251
+ ns_groups = {}
252
+ schemas_data.each do |schema|
253
+ ns = schema[:namespace]
254
+ # Skip nil-namespace schemas — chameleon schemas should not be merged
255
+ next unless ns
256
+
257
+ ns_groups[ns] ||= []
258
+ ns_groups[ns] << schema
259
+ end
260
+
261
+ # Collect schemas that were NOT grouped (nil namespace)
262
+ merged_schemas = schemas_data.reject { |s| s[:namespace] }
263
+
264
+ ns_groups.each_value do |group|
265
+ if group.length == 1
266
+ merged_schemas << group.first
267
+ next
268
+ end
269
+
270
+ primary = group.find { |s| s[:is_entrypoint] }
271
+ primary ||= group.max_by { |s| content_weight(s) }
272
+ secondaries = group.reject { |s| s[:id] == primary[:id] }
273
+
274
+ merge_content_into!(primary, secondaries)
275
+ merged_schemas << primary
276
+ end
277
+
278
+ merged_schemas
279
+ end
280
+
281
+ # Measure content richness of a serialized schema for primary selection
282
+ #
283
+ # @param schema [Hash] Serialized schema data
284
+ # @return [Integer] Total item count across content fields
285
+ def content_weight(schema)
286
+ MERGEABLE_CONTENT_FIELDS.sum { |f| (schema[f] || []).length }
287
+ end
288
+
289
+ # Merge content arrays from secondary schemas into the primary schema
290
+ #
291
+ # Uses Set for O(1) deduplication by hash identity.
292
+ #
293
+ # @param primary [Hash] Primary schema to merge into (mutated)
294
+ # @param secondaries [Array<Hash>] Secondary schemas to absorb
295
+ # @return [void]
296
+ def merge_content_into!(primary, secondaries)
297
+ MERGEABLE_CONTENT_FIELDS.each do |field|
298
+ primary[field] ||= []
299
+ seen = primary[field].to_set
300
+ secondaries.each do |sec|
301
+ (sec[field] || []).each do |item|
302
+ primary[field] << item unless seen.include?(item)
303
+ end
304
+ end
305
+ end
306
+
307
+ # Merge includes and imports (deduplicated by hash equality)
308
+ %i[includes imports].each do |field|
309
+ primary[field] ||= []
310
+ seen = primary[field].to_set
311
+ secondaries.each do |sec|
312
+ (sec[field] || []).each do |item|
313
+ primary[field] << item unless seen.include?(item)
314
+ end
315
+ end
316
+ end
317
+
318
+ # Collect all file paths
319
+ all_paths = [primary[:file_path]]
320
+ secondaries.each { |s| all_paths << s[:file_path] if s[:file_path] }
321
+ primary[:file_paths] = all_paths.compact
322
+ end
323
+
226
324
  # Serialize single schema (template method hook)
227
325
  #
228
326
  # Subclasses should override to customize schema serialization
@@ -746,24 +844,29 @@ schema_source = nil)
746
844
  end.sort_by { |ag| ag[:name] || "" }
747
845
  end
748
846
 
749
- # Extract source information for an attribute group
750
- # from the schema
847
+ # Extract source XML for a schema component identified by type, key, value
848
+ #
849
+ # @param type [String] XSD element type name (e.g., "attributeGroup")
850
+ # @param key [String] Attribute name to match on (e.g., "name")
851
+ # @param value [String] Attribute value to match
852
+ # @param prefix [String, nil] Optional namespace prefix
853
+ # @param source [String, nil] Raw XSD source XML
854
+ # @return [String, nil] Extracted source XML or nil
751
855
  def extract_source_by_type_key_value(type, key, value, prefix = nil,
752
856
  source = nil)
753
857
  return nil unless source && value
754
858
 
755
- # parse the schema source and find the attribute group by name
756
859
  begin
757
860
  doc = Moxml::Context.new.parse(source)
861
+ escaped_value = value.gsub("'", "''")
758
862
  xpath = if prefix
759
- "//#{prefix}:#{type}[@#{key}='#{value}']"
863
+ "//#{prefix}:#{type}[@#{key}='#{escaped_value}']"
760
864
  else
761
- "//#{type}[@#{key}='#{value}']"
865
+ "//#{type}[@#{key}='#{escaped_value}']"
762
866
  end
763
- ag_node = doc.at_xpath(xpath)
764
- ag_node&.to_xml(indent: 2)
867
+ node = doc.at_xpath(xpath)
868
+ node&.to_xml(indent: 2)
765
869
  rescue StandardError
766
- # If parsing fails, return nil
767
870
  nil
768
871
  end
769
872
  end
@@ -889,45 +992,52 @@ source = nil)
889
992
  end
890
993
  end
891
994
 
995
+ # Collect attribute group references from a model (handles extension nesting)
996
+ #
997
+ # Used for both direct attribute groups and those inside content model extensions.
998
+ #
999
+ # @param model [Object] Any object that may have attribute_group or extension
1000
+ # @return [Array<Object>] Collected attribute group reference objects
1001
+ def collect_attribute_group_refs(model)
1002
+ refs = []
1003
+
1004
+ if model.respond_to?(:attribute_group) && model.attribute_group
1005
+ groups = model.attribute_group.is_a?(Array) ? model.attribute_group : [model.attribute_group]
1006
+ refs.concat(groups)
1007
+ end
1008
+
1009
+ if model.respond_to?(:extension) && model.extension
1010
+ refs.concat(collect_attribute_group_refs(model.extension))
1011
+ end
1012
+
1013
+ refs
1014
+ end
1015
+
892
1016
  # Serialize attribute group references from a complex type
893
1017
  #
1018
+ # Collects attribute group refs from three possible locations:
1019
+ # 1. Direct attribute groups on the type
1020
+ # 2. Inside simpleContent.extension
1021
+ # 3. Inside complexContent.extension
1022
+ #
894
1023
  # @param type [ComplexType] Complex type
895
1024
  # @return [Array<Hash>] Serialized attribute group references with attributes
896
1025
  def serialize_type_attr_groups(type)
897
- # Collect attribute group refs from all three possible locations:
898
- # 1. Direct attribute groups on the type
899
- # 2. Inside simpleContent.extension
900
- # 3. Inside complexContent.extension
1026
+ return [] unless type
1027
+
901
1028
  all_ag_refs = []
902
1029
 
903
- # 1. Direct attribute groups
904
1030
  if type.respond_to?(:attribute_group) && type.attribute_group
905
1031
  direct_groups = type.attribute_group.is_a?(Array) ? type.attribute_group : [type.attribute_group]
906
1032
  all_ag_refs.concat(direct_groups)
907
1033
  end
908
1034
 
909
- # 2. Attribute groups inside simpleContent.extension
910
1035
  if type.respond_to?(:simple_content) && type.simple_content
911
- sc = type.simple_content
912
- if sc.respond_to?(:extension) && sc.extension
913
- extension = sc.extension
914
- if extension.respond_to?(:attribute_group) && extension.attribute_group
915
- ext_groups = extension.attribute_group.is_a?(Array) ? extension.attribute_group : [extension.attribute_group]
916
- all_ag_refs.concat(ext_groups)
917
- end
918
- end
1036
+ all_ag_refs.concat(collect_attribute_group_refs(type.simple_content))
919
1037
  end
920
1038
 
921
- # 3. Attribute groups inside complexContent.extension
922
1039
  if type.respond_to?(:complex_content) && type.complex_content
923
- cc = type.complex_content
924
- if cc.respond_to?(:extension) && cc.extension
925
- extension = cc.extension
926
- if extension.respond_to?(:attribute_group) && extension.attribute_group
927
- ext_groups = extension.attribute_group.is_a?(Array) ? extension.attribute_group : [extension.attribute_group]
928
- all_ag_refs.concat(ext_groups)
929
- end
930
- end
1040
+ all_ag_refs.concat(collect_attribute_group_refs(type.complex_content))
931
1041
  end
932
1042
 
933
1043
  return [] if all_ag_refs.empty?
@@ -936,12 +1046,8 @@ source = nil)
936
1046
  ag_name = ag.respond_to?(:ref) ? ag.ref : ag.name
937
1047
  next unless ag_name
938
1048
 
939
- # Look up the actual attribute group definition to get its attributes
940
1049
  attrs = lookup_attribute_group_attributes(ag_name)
941
- {
942
- ref: ag_name,
943
- attributes: attrs,
944
- }
1050
+ { ref: ag_name, attributes: attrs }
945
1051
  end
946
1052
  end
947
1053
 
@@ -1180,74 +1286,43 @@ source = nil)
1180
1286
  }
1181
1287
  end
1182
1288
 
1289
+ # Facet serializers: maps facet name to how to extract and format it
1290
+ #
1291
+ # Each entry: [method_name, facet_type_label]
1292
+ # method_name — what restriction.respond_to?(:method_name) && restriction.method_name to call
1293
+ # facet_type — the type string emitted in serialized facet
1294
+ FACET_METHODS = [
1295
+ [:enumerations, "enumeration"],
1296
+ [:pattern, "pattern"],
1297
+ [:min_length, "min_length"],
1298
+ [:max_length, "max_length"],
1299
+ [:length, "length"],
1300
+ [:min_inclusive, "min_inclusive"],
1301
+ [:max_inclusive, "max_inclusive"],
1302
+ [:min_exclusive, "min_exclusive"],
1303
+ [:max_exclusive, "max_exclusive"],
1304
+ [:total_digits, "total_digits"],
1305
+ [:fraction_digits, "fraction_digits"],
1306
+ [:white_space, "white_space"],
1307
+ ].freeze
1308
+
1183
1309
  # Serialize facets
1184
1310
  #
1185
1311
  # @param restriction [Restriction] Restriction object
1186
1312
  # @return [Array<Hash>] Serialized facets
1187
1313
  def serialize_facets(restriction)
1188
- facets = []
1189
-
1190
- if restriction.respond_to?(:enumerations) && restriction.enumerations
1191
- facets << { type: "enumeration",
1192
- values: restriction.enumerations }
1193
- end
1194
-
1195
- if restriction.respond_to?(:pattern) && restriction.pattern
1196
- facets << { type: "pattern",
1197
- value: restriction.pattern }
1198
- end
1199
-
1200
- if restriction.respond_to?(:min_length) && restriction.min_length
1201
- facets << { type: "min_length",
1202
- value: restriction.min_length }
1203
- end
1204
-
1205
- if restriction.respond_to?(:max_length) && restriction.max_length
1206
- facets << { type: "max_length",
1207
- value: restriction.max_length }
1208
- end
1209
-
1210
- if restriction.respond_to?(:length) && restriction.length
1211
- facets << { type: "length",
1212
- value: restriction.length }
1213
- end
1314
+ return [] unless restriction
1214
1315
 
1215
- if restriction.respond_to?(:min_inclusive) && restriction.min_inclusive
1216
- facets << { type: "min_inclusive",
1217
- value: restriction.min_inclusive }
1218
- end
1219
-
1220
- if restriction.respond_to?(:max_inclusive) && restriction.max_inclusive
1221
- facets << { type: "max_inclusive",
1222
- value: restriction.max_inclusive }
1223
- end
1316
+ FACET_METHODS.filter_map do |method_name, facet_type|
1317
+ value = restriction.respond_to?(method_name) && restriction.send(method_name)
1318
+ next unless value
1224
1319
 
1225
- if restriction.respond_to?(:min_exclusive) && restriction.min_exclusive
1226
- facets << { type: "min_exclusive",
1227
- value: restriction.min_exclusive }
1228
- end
1229
-
1230
- if restriction.respond_to?(:max_exclusive) && restriction.max_exclusive
1231
- facets << { type: "max_exclusive",
1232
- value: restriction.max_exclusive }
1233
- end
1234
-
1235
- if restriction.respond_to?(:total_digits) && restriction.total_digits
1236
- facets << { type: "total_digits",
1237
- value: restriction.total_digits }
1238
- end
1239
-
1240
- if restriction.respond_to?(:fraction_digits) && restriction.fraction_digits
1241
- facets << { type: "fraction_digits",
1242
- value: restriction.fraction_digits }
1243
- end
1244
-
1245
- if restriction.respond_to?(:white_space) && restriction.white_space
1246
- facets << { type: "white_space",
1247
- value: restriction.white_space }
1320
+ if method_name == :enumerations
1321
+ { type: facet_type, values: value }
1322
+ else
1323
+ { type: facet_type, value: value }
1324
+ end
1248
1325
  end
1249
-
1250
- facets
1251
1326
  end
1252
1327
 
1253
1328
  # Extract documentation from object
@@ -1279,29 +1354,34 @@ source = nil)
1279
1354
  "empty"
1280
1355
  end
1281
1356
 
1357
+ # Extract base type from a content model's extension or restriction
1358
+ #
1359
+ # @param content_model [Object] simpleContent or complexContent
1360
+ # @return [String, nil] Base type name
1361
+ def base_from_content_model(content_model)
1362
+ if content_model.respond_to?(:extension) && content_model.extension
1363
+ ext = content_model.extension
1364
+ return ext.base if ext.respond_to?(:base)
1365
+ elsif content_model.respond_to?(:restriction) && content_model.restriction
1366
+ rst = content_model.restriction
1367
+ return rst.base if rst.respond_to?(:base)
1368
+ end
1369
+ nil
1370
+ end
1371
+
1282
1372
  # Extract base type from complex type
1283
1373
  #
1284
1374
  # @param type [ComplexType] Complex type
1285
1375
  # @return [String, nil] Base type name
1286
1376
  def extract_base_type(type)
1287
- # Check complex_content for extension or restriction
1288
1377
  if type.respond_to?(:complex_content) && type.complex_content
1289
- cc = type.complex_content
1290
- if cc.respond_to?(:extension) && cc.extension
1291
- return cc.extension.base if cc.extension.respond_to?(:base)
1292
- elsif cc.respond_to?(:restriction) && cc.restriction
1293
- return cc.restriction.base if cc.restriction.respond_to?(:base)
1294
- end
1378
+ base = base_from_content_model(type.complex_content)
1379
+ return base if base
1295
1380
  end
1296
1381
 
1297
- # Check simple_content for extension or restriction
1298
1382
  if type.respond_to?(:simple_content) && type.simple_content
1299
- sc = type.simple_content
1300
- if sc.respond_to?(:extension) && sc.extension
1301
- return sc.extension.base if sc.extension.respond_to?(:base)
1302
- elsif sc.respond_to?(:restriction) && sc.restriction
1303
- return sc.restriction.base if sc.restriction.respond_to?(:base)
1304
- end
1383
+ base = base_from_content_model(type.simple_content)
1384
+ return base if base
1305
1385
  end
1306
1386
 
1307
1387
  nil
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Xsd
5
- VERSION = "1.1.0"
5
+ VERSION = "1.1.1"
6
6
  end
7
7
  end
data/lutaml-xsd.gemspec CHANGED
@@ -31,7 +31,7 @@ Gem::Specification.new do |spec|
31
31
  end + Dir.glob("frontend/dist/*")
32
32
  end
33
33
 
34
- spec.add_dependency "liquid", "~> 5.0"
34
+ spec.add_dependency "liquid", ">= 4.0", "< 6.0"
35
35
  spec.add_dependency "lutaml-model", "~> 0.8.0"
36
36
  spec.add_dependency "moxml"
37
37
  spec.add_dependency "paint", "~> 2.3"
metadata CHANGED
@@ -1,29 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-xsd
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-27 00:00:00.000000000 Z
11
+ date: 2026-05-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: liquid
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4.0'
20
+ - - "<"
18
21
  - !ruby/object:Gem::Version
19
- version: '5.0'
22
+ version: '6.0'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
- - - "~>"
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '4.0'
30
+ - - "<"
25
31
  - !ruby/object:Gem::Version
26
- version: '5.0'
32
+ version: '6.0'
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: lutaml-model
29
35
  requirement: !ruby/object:Gem::Requirement