dcc 0.2.0 → 0.2.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.
Files changed (167) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +19 -0
  3. data/.github/workflows/release.yml +28 -0
  4. data/.gitignore +4 -0
  5. data/.rubocop_todo.yml +560 -10
  6. data/CHANGELOG.adoc +34 -1
  7. data/CONTRIBUTING.adoc +8 -1
  8. data/Gemfile +5 -7
  9. data/README.adoc +162 -7
  10. data/dcc.gemspec +8 -2
  11. data/docs/schema-versions.adoc +20 -7
  12. data/examples/build_minimal_dcc.rb +0 -0
  13. data/examples/convert_all_formats.rb +2 -1
  14. data/examples/parse_and_inspect.rb +0 -0
  15. data/examples/quantity_math_demo.rb +7 -7
  16. data/examples/validate_full.rb +1 -1
  17. data/lib/dcc/base/calibration_laboratory.rb +2 -1
  18. data/lib/dcc/base/condition.rb +5 -2
  19. data/lib/dcc/base/core_data.rb +8 -3
  20. data/lib/dcc/base/formula.rb +3 -3
  21. data/lib/dcc/base/identification.rb +6 -3
  22. data/lib/dcc/base/mathml.rb +33 -0
  23. data/lib/dcc/base/quantity.rb +2 -1
  24. data/lib/dcc/base/resp_person.rb +4 -2
  25. data/lib/dcc/base/text.rb +1 -1
  26. data/lib/dcc/base.rb +2 -1
  27. data/lib/dcc/builder/dsl.rb +1 -1
  28. data/lib/dcc/builder/session.rb +16 -14
  29. data/lib/dcc/builder.rb +3 -3
  30. data/lib/dcc/cli/cli.rb +36 -18
  31. data/lib/dcc/cli/formatters.rb +35 -7
  32. data/lib/dcc/cli.rb +1 -1
  33. data/lib/dcc/context_configuration.rb +2 -1
  34. data/lib/dcc/context_options.rb +4 -2
  35. data/lib/dcc/convert/csv.rb +27 -9
  36. data/lib/dcc/convert/html.rb +26 -110
  37. data/lib/dcc/convert/json.rb +10 -7
  38. data/lib/dcc/convert/liquid_filters.rb +58 -0
  39. data/lib/dcc/convert/result.rb +1 -1
  40. data/lib/dcc/convert/templates/report.liquid +250 -0
  41. data/lib/dcc/convert/yaml.rb +1 -1
  42. data/lib/dcc/convert.rb +1 -0
  43. data/lib/dcc/diff/change.rb +1 -1
  44. data/lib/dcc/diff/result.rb +8 -4
  45. data/lib/dcc/diff.rb +43 -11
  46. data/lib/dcc/error.rb +7 -0
  47. data/lib/dcc/extract/file.rb +3 -4
  48. data/lib/dcc/extract/ring.rb +1 -1
  49. data/lib/dcc/extract.rb +1 -1
  50. data/lib/dcc/i18n/text_lookup.rb +8 -4
  51. data/lib/dcc/i18n.rb +1 -1
  52. data/lib/dcc/inspect/report.rb +51 -14
  53. data/lib/dcc/inspect.rb +1 -1
  54. data/lib/dcc/migrate/route.rb +44 -0
  55. data/lib/dcc/migrate/v2_to_v3.rb +445 -0
  56. data/lib/dcc/migrate.rb +130 -26
  57. data/lib/dcc/plugin/base.rb +64 -0
  58. data/lib/dcc/plugin/registry.rb +7 -2
  59. data/lib/dcc/plugin.rb +6 -2
  60. data/lib/dcc/quantity_format/formatter.rb +16 -10
  61. data/lib/dcc/quantity_format.rb +1 -1
  62. data/lib/dcc/quantity_math/quantity.rb +1 -1
  63. data/lib/dcc/quantity_math/real.rb +6 -6
  64. data/lib/dcc/quantity_math.rb +1 -1
  65. data/lib/dcc/schema/version.rb +2 -2
  66. data/lib/dcc/server/app.html +282 -0
  67. data/lib/dcc/server/app.rb +171 -87
  68. data/lib/dcc/server/demo_cert.xml +244 -0
  69. data/lib/dcc/server/storage/entry.rb +1 -1
  70. data/lib/dcc/server/storage/memory.rb +3 -2
  71. data/lib/dcc/server/storage.rb +1 -1
  72. data/lib/dcc/server.rb +1 -1
  73. data/lib/dcc/si/base/complex.rb +1 -1
  74. data/lib/dcc/si/base/complex_list_xml_list.rb +1 -1
  75. data/lib/dcc/si/base/constant.rb +1 -1
  76. data/lib/dcc/si/base/coverage_interval.rb +36 -0
  77. data/lib/dcc/si/base/coverage_interval_mu.rb +1 -1
  78. data/lib/dcc/si/base/expanded_mu.rb +1 -1
  79. data/lib/dcc/si/base/expanded_unc.rb +34 -0
  80. data/lib/dcc/si/base/expanded_unc_xml_list.rb +3 -2
  81. data/lib/dcc/si/base/hybrid.rb +3 -2
  82. data/lib/dcc/si/base/measurement_uncertainty_univariate.rb +1 -1
  83. data/lib/dcc/si/base/real.rb +13 -3
  84. data/lib/dcc/si/base/real_list_xml_list.rb +5 -3
  85. data/lib/dcc/si/base/si_list.rb +3 -2
  86. data/lib/dcc/si/base/standard_mu.rb +1 -1
  87. data/lib/dcc/si/base.rb +5 -2
  88. data/lib/dcc/si/v1/coverage_interval.rb +8 -0
  89. data/lib/dcc/si/v1/expanded_unc.rb +1 -16
  90. data/lib/dcc/si/v1/measurement_uncertainty_univariate.rb +2 -1
  91. data/lib/dcc/si/v1.rb +5 -2
  92. data/lib/dcc/si/v2/coverage_interval.rb +8 -0
  93. data/lib/dcc/si/v2/coverage_interval_mu_xml_list.rb +4 -2
  94. data/lib/dcc/si/v2/expanded_mu_xml_list.rb +2 -1
  95. data/lib/dcc/si/v2/expanded_unc.rb +8 -0
  96. data/lib/dcc/si/v2/measurement_uncertainty_univariate.rb +2 -1
  97. data/lib/dcc/si/v2.rb +8 -3
  98. data/lib/dcc/signature/result.rb +2 -2
  99. data/lib/dcc/signature/signer.rb +31 -18
  100. data/lib/dcc/signature/verifier.rb +23 -21
  101. data/lib/dcc/signature.rb +1 -1
  102. data/lib/dcc/transform/result.rb +1 -1
  103. data/lib/dcc/transform/xslt.rb +22 -12
  104. data/lib/dcc/transform.rb +5 -4
  105. data/lib/dcc/type/base64_binary.rb +5 -3
  106. data/lib/dcc/type/decimal_xml_list.rb +99 -13
  107. data/lib/dcc/type/iso_country_code.rb +3 -2
  108. data/lib/dcc/type/iso_language_code.rb +3 -2
  109. data/lib/dcc/type/schema_version.rb +3 -2
  110. data/lib/dcc/type/si_unit.rb +3 -2
  111. data/lib/dcc/type_guards.rb +3 -3
  112. data/lib/dcc/v2/calibration_laboratory.rb +2 -1
  113. data/lib/dcc/v2/condition.rb +22 -1
  114. data/lib/dcc/v2/configuration.rb +1 -1
  115. data/lib/dcc/v2/digital_calibration_certificate.rb +2 -1
  116. data/lib/dcc/v2/identification.rb +14 -1
  117. data/lib/dcc/v2/mathml.rb +8 -0
  118. data/lib/dcc/v2/measurement_meta_data_list.rb +2 -1
  119. data/lib/dcc/v2/quantity.rb +4 -2
  120. data/lib/dcc/v2.rb +4 -2
  121. data/lib/dcc/v3/calibration_laboratory.rb +2 -1
  122. data/lib/dcc/v3/configuration.rb +1 -1
  123. data/lib/dcc/v3/digital_calibration_certificate.rb +2 -1
  124. data/lib/dcc/v3/mathml.rb +8 -0
  125. data/lib/dcc/v3/measurement_meta_data_list.rb +2 -1
  126. data/lib/dcc/v3/quantity.rb +4 -2
  127. data/lib/dcc/v3.rb +12 -2
  128. data/lib/dcc/validate/business_rules.rb +23 -9
  129. data/lib/dcc/validate/issue.rb +2 -1
  130. data/lib/dcc/validate/result.rb +6 -4
  131. data/lib/dcc/validate/schematron/profile.rb +73 -18
  132. data/lib/dcc/validate/schematron/rule.rb +13 -3
  133. data/lib/dcc/validate/schematron/rules/administrative_data_completeness.rb +11 -4
  134. data/lib/dcc/validate/schematron/rules/base.rb +1 -2
  135. data/lib/dcc/validate/schematron/rules/date_range_check.rb +11 -5
  136. data/lib/dcc/validate/schematron/rules/id_refid_linking.rb +0 -3
  137. data/lib/dcc/validate/schematron/rules/influence_conditions_placement.rb +22 -12
  138. data/lib/dcc/validate/schematron/rules/iso_code_validation.rb +31 -14
  139. data/lib/dcc/validate/schematron/rules/language_code_dedup.rb +12 -5
  140. data/lib/dcc/validate/schematron/rules/non_si_declaration.rb +7 -4
  141. data/lib/dcc/validate/schematron/rules/release_format_check.rb +7 -4
  142. data/lib/dcc/validate/schematron/rules/schema_version_check.rb +13 -9
  143. data/lib/dcc/validate/schematron/rules/uncertainty_consistency.rb +39 -10
  144. data/lib/dcc/validate/schematron/rules/unit_format_check.rb +2 -3
  145. data/lib/dcc/validate/schematron/rules/used_methods_placement.rb +15 -10
  146. data/lib/dcc/validate/schematron/rules/used_software_placement.rb +15 -10
  147. data/lib/dcc/validate/schematron/rules/xml_list_spacing.rb +4 -3
  148. data/lib/dcc/validate/schematron/rules.rb +28 -14
  149. data/lib/dcc/validate/schematron.rb +1 -1
  150. data/lib/dcc/validate/xsd.rb +9 -3
  151. data/lib/dcc/validate.rb +1 -1
  152. data/lib/dcc/version.rb +1 -1
  153. data/lib/dcc/versioned_parser.rb +1 -1
  154. data/lib/dcc.rb +55 -7
  155. data/sample_dcc.xml +140 -0
  156. data/web/index.html +12 -0
  157. data/web/package-lock.json +1599 -0
  158. data/web/package.json +22 -0
  159. data/web/src/App.vue +349 -0
  160. data/web/src/lib/builder.ts +171 -0
  161. data/web/src/lib/dcc.ts +338 -0
  162. data/web/src/lib/units.json +5581 -0
  163. data/web/src/main.ts +5 -0
  164. data/web/src/style.css +27 -0
  165. data/web/tsconfig.json +15 -0
  166. data/web/vite.config.ts +14 -0
  167. metadata +125 -9
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dcc
4
+ module Migrate
5
+ # Registry of migrations whose full incompatibility set has been verified
6
+ # against the bundled XSDs.
7
+ #
8
+ # Unregistered pairs raise rather than run. DCC 2.1.0 and 2.1.1 sit in the
9
+ # `https://ptb.de/si/smartcom/d-si/v1_0_1` D-SI namespace, which no
10
+ # transform here addresses, so accepting them by major version alone would
11
+ # silently emit an unmigrated document.
12
+ module Route
13
+ TRANSFORMS = { %w[2.3.0 3.3.0] => :V2ToV3 }.freeze
14
+
15
+ # v3.3.0 is a strict superset of v3.2.1 at instance level; only the
16
+ # `schemaVersion` pattern differs.
17
+ SCHEMA_VERSION_ONLY = [%w[3.2.1 3.3.0]].freeze
18
+
19
+ class << self
20
+ # @param from [String] normalized source version, e.g. "2.3.0".
21
+ # @param to [String] normalized target version, e.g. "3.3.0".
22
+ # @return [Boolean] whether this migration is registered.
23
+ def supported?(from, to)
24
+ TRANSFORMS.key?([from, to]) ||
25
+ SCHEMA_VERSION_ONLY.include?([from, to])
26
+ end
27
+
28
+ # @return [Module, nil] a module responding to
29
+ # `.call(xml, to:, on_loss:)`, or nil when only `schemaVersion`
30
+ # needs rewriting.
31
+ def for(from, to)
32
+ name = TRANSFORMS[[from, to]]
33
+ name && ::Dcc::Migrate.const_get(name)
34
+ end
35
+
36
+ # @return [Array<String>] every registered pair, for error messages.
37
+ def supported_pairs
38
+ (TRANSFORMS.keys + SCHEMA_VERSION_ONLY)
39
+ .map { |from, to| "#{from} to #{to}" }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,445 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Dcc
6
+ module Migrate
7
+ # Rewrites a DCC v2.x document into the v3.x wire format.
8
+ #
9
+ # v3 renamed several elements, renamed the `hashType` children on all three
10
+ # v2 elements carrying that type, made `coreData/performanceLocation` and
11
+ # `measurementResult/name` mandatory, moved the statement `refId` from a
12
+ # child element to an attribute, narrowed four `description` elements to
13
+ # single occurrence, tightened many string types to reject surrounding
14
+ # whitespace, and switched from D-SI v1 to D-SI v2.
15
+ #
16
+ # Two v2 constructs have no v3 home — `formula/@lang` and the second and
17
+ # later `calibrationLaboratory/contact`. Those, the element ids lost to a
18
+ # description merge, and every trimmed value are reported rather than
19
+ # dropped in silence.
20
+ #
21
+ # Every rule is conditional, so applying the transform twice is a no-op.
22
+ module V2ToV3
23
+ NS = {
24
+ "dcc" => "https://ptb.de/dcc",
25
+ "si" => "https://ptb.de/si",
26
+ }.freeze
27
+
28
+ ROOT_ELEMENT = "digitalCalibrationCertificate"
29
+
30
+ # Only the canonical URI. `Dcc::Namespace::Dcc` also accepts the
31
+ # `https://ptb.de/dcc.xsd` alias, but every rule below binds `dcc:` to
32
+ # the canonical URI, so an alias document would match no xpath: it would
33
+ # come back unmigrated, reporting nothing, and fail the v3 schema.
34
+ # Refusing it says so instead of returning a quietly broken document.
35
+ ROOT_NAMESPACE = NS["dcc"]
36
+
37
+ # `performanceLocationType` is an enum and v2 records no location, so
38
+ # the catch-all is the only truthful value.
39
+ DEFAULT_PERFORMANCE_LOCATION = "other"
40
+ DEFAULT_MEASUREMENT_RESULT_NAME = "Measurement result"
41
+
42
+ # `byteData`, `descriptionData` and `document` are all typed
43
+ # `byteDataType`, so all three carry the `data` child v3 renamed.
44
+ BYTE_DATA_DATA_XPATH = [
45
+ "//dcc:byteData/dcc:data",
46
+ "//dcc:descriptionData/dcc:data",
47
+ "//dcc:document/dcc:data",
48
+ ].join(" | ").freeze
49
+
50
+ # The three elements v2.3.0 types `dcc:hashType`. v3 renamed that type's
51
+ # `reference`/`referenceID` children, so all three need the rename, not
52
+ # `previousReport` alone. `linkedReport` is itself a `hashType` child, so
53
+ # the `//` anchor covers nesting at any depth.
54
+ #
55
+ # A fourth `identifier` appears in the file but sits inside the
56
+ # commented-out `metrologicallyTraceableType` block (v2.3.0
57
+ # dcc.xsd:325-341), so no valid v2 document can carry it.
58
+ #
59
+ # The anchor is what keeps this safe: v2 also declares `reference` under
60
+ # `equipmentClass` and `statement`, and v3 keeps BOTH of those named
61
+ # `reference`. An unanchored `//dcc:reference` would corrupt them.
62
+ HASH_TYPE_PARENTS = %w[previousReport certificate linkedReport].freeze
63
+
64
+ HASH_TYPE_REFERENCE_XPATH = HASH_TYPE_PARENTS
65
+ .map { |parent| "//dcc:#{parent}/dcc:reference" }.join(" | ").freeze
66
+ HASH_TYPE_REFERENCE_ID_XPATH = HASH_TYPE_PARENTS
67
+ .map { |parent| "//dcc:#{parent}/dcc:referenceID" }.join(" | ").freeze
68
+
69
+ RENAMES = {
70
+ "//dcc:identification/dcc:description" => "name",
71
+ "//dcc:influenceCondition/dcc:state" => "status",
72
+ HASH_TYPE_REFERENCE_XPATH => "referral",
73
+ HASH_TYPE_REFERENCE_ID_XPATH => "referralID",
74
+ BYTE_DATA_DATA_XPATH => "dataBase64",
75
+ "//dcc:formula/dcc:siunitx" => "latex",
76
+ }.freeze
77
+
78
+ # v2 let these `description` elements repeat; v3 allows one.
79
+ MERGED_DESCRIPTION_PARENTS = %w[
80
+ software item measuringEquipment usedMethod
81
+ ].freeze
82
+
83
+ # Elements retyped to `notEmptyStringType`, which rejects leading and
84
+ # trailing whitespace as well as empty values.
85
+ TRIMMED_ELEMENTS = %w[
86
+ release model uniqueIdentifier value classID reference norm
87
+ convention calibrationLaboratoryCode city postCode postOfficeBox
88
+ state street streetNo eMail phone fax referralID procedure
89
+ fileName mimeType
90
+ ].freeze
91
+
92
+ # Children a v2.2.1 `si:list` accepts directly; anything else in a v1
93
+ # list body has to move into a wrapper.
94
+ LIST_CONTAINER_CHILDREN = %w[
95
+ label dateTime realList realListXMLList complexList
96
+ complexListXMLList list
97
+ ].freeze
98
+
99
+ class << self
100
+ # Every route in `Dcc::Migrate` funnels raw input through here, not
101
+ # just this transform: this file is the only one sanctioned to parse
102
+ # XML directly, so the DCC-root check has to live with it.
103
+ #
104
+ # @param xml [String] candidate DCC XML.
105
+ # @return [String] the same XML, when the root is a DCC root.
106
+ # @raise [Dcc::ParseError] otherwise.
107
+ def assert_dcc_root(xml)
108
+ reject_rootless(parse(xml))
109
+ xml
110
+ end
111
+
112
+ # @param xml [String] DCC v2 XML.
113
+ # @param to [String] target v3 version for `schemaVersion`.
114
+ # @param on_loss [#call, nil] receives each loss message (a String)
115
+ # instead of the stderr report; nil keeps the `Kernel.warn` report.
116
+ # @return [String] DCC v3 XML.
117
+ # @raise [Dcc::ParseError] on malformed XML or a non-DCC root.
118
+ def call(xml, to:, on_loss: nil)
119
+ doc = parse(xml)
120
+ reject_rootless(doc)
121
+
122
+ losses = []
123
+ doc.root["schemaVersion"] = to
124
+ apply_rules(doc, losses)
125
+ report(losses, to, on_loss)
126
+
127
+ doc.to_xml
128
+ end
129
+
130
+ private
131
+
132
+ # Strict, so a truncated or malformed document is refused rather than
133
+ # silently recovered into a partial one. Nokogiri's default recovery
134
+ # mode would drop everything after the first syntax error, and the
135
+ # migration would report no loss for the sections it never saw.
136
+ def parse(xml)
137
+ ::Nokogiri::XML(xml, &:strict)
138
+ rescue ::Nokogiri::XML::SyntaxError => e
139
+ raise ::Dcc::ParseError,
140
+ "Dcc::Migrate::V2ToV3 expects well-formed DCC XML: #{e.message}"
141
+ end
142
+
143
+ # Checking the name and namespace, not merely that a root exists: the
144
+ # target parser builds an empty certificate out of a foreign root
145
+ # rather than refusing it, so a stray document would otherwise migrate
146
+ # to a valid-looking but empty v3 result.
147
+ def reject_rootless(doc)
148
+ root = doc.root
149
+ return if root && root.name == ROOT_ELEMENT &&
150
+ root.namespace&.href == ROOT_NAMESPACE
151
+
152
+ raise ::Dcc::ParseError,
153
+ "Dcc.migrate expects a <dcc:#{ROOT_ELEMENT}> root " \
154
+ "in the #{ROOT_NAMESPACE} namespace, got " \
155
+ "#{describe_root(root)}."
156
+ end
157
+
158
+ def describe_root(root)
159
+ return "input with no root element" unless root
160
+
161
+ "<#{root.name}> in #{root.namespace&.href.inspect}"
162
+ end
163
+
164
+ def apply_rules(doc, losses)
165
+ rename_elements(doc)
166
+ move_ref_id_to_attribute(doc)
167
+ add_performance_location(doc)
168
+ add_measurement_result_names(doc)
169
+ merge_descriptions(doc, losses)
170
+ rehome_description_data(doc)
171
+ trim_not_empty_strings(doc, losses)
172
+ upgrade_dsi(doc)
173
+ collect_losses(doc, losses)
174
+ end
175
+
176
+ def rename_elements(doc)
177
+ RENAMES.each do |xpath, name|
178
+ doc.xpath(xpath, NS).each { |node| node.name = name }
179
+ end
180
+ end
181
+
182
+ def move_ref_id_to_attribute(doc)
183
+ doc.xpath("//dcc:statement/dcc:refId | //dcc:metaData/dcc:refId", NS)
184
+ .each do |node|
185
+ node.parent["refId"] = node.text.strip
186
+ node.remove
187
+ end
188
+ end
189
+
190
+ def add_performance_location(doc)
191
+ doc.xpath("//dcc:coreData", NS).each do |core_data|
192
+ next if core_data.at_xpath("dcc:performanceLocation", NS)
193
+
194
+ node = element(doc, "dcc:performanceLocation")
195
+ node.content = DEFAULT_PERFORMANCE_LOCATION
196
+ performance_anchor(core_data).add_next_sibling(node)
197
+ end
198
+ end
199
+
200
+ # `endPerformanceDate` is mandatory in v2, so its absence means the
201
+ # input is not the v2 document this transform was handed.
202
+ def performance_anchor(core_data)
203
+ core_data.at_xpath("dcc:endPerformanceDate", NS) ||
204
+ raise(::Dcc::ParseError,
205
+ "coreData has no endPerformanceDate, so the mandatory v3 " \
206
+ "performanceLocation cannot be placed.")
207
+ end
208
+
209
+ def add_measurement_result_names(doc)
210
+ doc.xpath("//dcc:measurementResult", NS).each do |result|
211
+ next if result.at_xpath("dcc:name", NS)
212
+
213
+ name = result_name(doc)
214
+ anchor = result.children.first
215
+ anchor ? anchor.add_previous_sibling(name) : result.add_child(name)
216
+ end
217
+ end
218
+
219
+ def result_name(doc)
220
+ name = element(doc, "dcc:name")
221
+ content = element(doc, "dcc:content")
222
+ content.content = DEFAULT_MEASUREMENT_RESULT_NAME
223
+ name.add_child(content)
224
+ name
225
+ end
226
+
227
+ # v2 `textType` carries an optional `@id` and real documents use it.
228
+ # Merging removes the ids of descriptions 2..N, so every reference to
229
+ # one is repointed at the survivor before the element goes away.
230
+ def merge_descriptions(doc, losses)
231
+ remap = {}
232
+ MERGED_DESCRIPTION_PARENTS.each do |parent|
233
+ doc.xpath("//dcc:#{parent}", NS)
234
+ .each { |node| merge_parent_descriptions(node, remap, losses) }
235
+ end
236
+ repoint_references(doc, remap, losses)
237
+ end
238
+
239
+ def merge_parent_descriptions(node, remap, losses)
240
+ first, *rest = node.xpath("dcc:description", NS).to_a
241
+ rest.each do |extra|
242
+ extra.element_children.each { |child| first.add_child(child) }
243
+ absorb_id(first, extra, remap, losses)
244
+ extra.remove
245
+ end
246
+ end
247
+
248
+ # The survivor ends up holding the removed description's content, so a
249
+ # reference to the removed id still means the same thing. Adopt the id
250
+ # outright when the survivor has none.
251
+ def absorb_id(first, extra, remap, losses)
252
+ id = extra["id"]
253
+ return unless id
254
+
255
+ first["id"] ||= id
256
+ return if first["id"] == id
257
+
258
+ remap[id] = first["id"]
259
+ losses << "description/@id=#{id} merged into #{first['id']}"
260
+ end
261
+
262
+ # Without this a `refId` would be left pointing at an id that no
263
+ # longer exists anywhere in the document.
264
+ def repoint_references(doc, remap, losses)
265
+ return if remap.empty?
266
+
267
+ doc.xpath("//*[@refId]").each do |node|
268
+ before = node["refId"].split
269
+ after = before.map { |token| remap.fetch(token, token) }
270
+ next if after == before
271
+
272
+ node["refId"] = after.join(" ")
273
+ losses << "#{node.name}/@refId repointed to #{after.join(' ')} " \
274
+ "(merged descriptions)"
275
+ end
276
+ end
277
+
278
+ # v3 dropped `descriptionData`; `richContentType/file` carries the
279
+ # same `byteDataType` payload.
280
+ def rehome_description_data(doc)
281
+ doc.xpath(
282
+ "//dcc:item/dcc:descriptionData | " \
283
+ "//dcc:measuringEquipment/dcc:descriptionData", NS
284
+ ).each do |node|
285
+ parent = node.parent
286
+ node.name = "file"
287
+ description(doc, parent).add_child(node)
288
+ end
289
+ end
290
+
291
+ def description(doc, parent)
292
+ parent.at_xpath("dcc:description", NS) ||
293
+ element(doc, "dcc:description").tap do |node|
294
+ parent.at_xpath("dcc:name", NS).add_next_sibling(node)
295
+ end
296
+ end
297
+
298
+ # `notEmptyStringType` rejects surrounding whitespace, so trimming is
299
+ # required for validity — but it changes a value the author wrote,
300
+ # which this transform reports rather than doing silently.
301
+ def trim_not_empty_strings(doc, losses)
302
+ TRIMMED_ELEMENTS.each do |name|
303
+ doc.xpath("//dcc:#{name}", NS)
304
+ .each { |node| trim_node(node, name, losses) }
305
+ end
306
+ end
307
+
308
+ def trim_node(node, name, losses)
309
+ return unless node.element_children.empty?
310
+
311
+ original = node.text
312
+ trimmed = original.strip
313
+ reject_empty(name, original) if trimmed.empty?
314
+ return if trimmed == original
315
+
316
+ node.content = trimmed
317
+ losses << "#{name} whitespace trimmed to #{trimmed.inspect} " \
318
+ "(v3 notEmptyStringType rejects it)"
319
+ end
320
+
321
+ # v2 typed these `xs:string` and accepted an empty or whitespace-only
322
+ # value; v3's `notEmptyStringType` rejects it and there is nothing
323
+ # truthful to put in its place.
324
+ def reject_empty(name, original)
325
+ raise ::Dcc::UnsupportedMigrationError,
326
+ "Cannot migrate <dcc:#{name}>#{original}</dcc:#{name}>: v3 " \
327
+ "types it notEmptyStringType, which rejects an empty or " \
328
+ "whitespace-only value, and no substitute can be invented."
329
+ end
330
+
331
+ def upgrade_dsi(doc)
332
+ wrap_uncertainty(doc, "expandedUnc", "expandedMU",
333
+ "uncertainty" => "valueExpandedMU")
334
+ wrap_uncertainty(doc, "coverageInterval", "coverageIntervalMU",
335
+ "standardUnc" => "valueStandardMU")
336
+ wrap_list_bodies(doc)
337
+ normalize_probabilities(doc)
338
+ end
339
+
340
+ # Only reals OUTSIDE an `si:list`. `realInListType` misspells the
341
+ # modern wrapper as `measurementUncertaintyUnivaraite` and accepts the
342
+ # deprecated forms as siblings, so rewriting a list-local uncertainty
343
+ # to the correct spelling would turn a valid document invalid. Both
344
+ # deprecated forms stay legal in v2.2.1, so leaving them costs
345
+ # nothing. `dcc:list` is a different element and does not match.
346
+ def wrap_uncertainty(doc, legacy_name, modern_name, renames)
347
+ doc.xpath("//si:real[not(ancestor::si:list)]/si:#{legacy_name}", NS)
348
+ .each do |legacy|
349
+ univariate = element(doc, "si:measurementUncertaintyUnivariate")
350
+ univariate.add_child(
351
+ modern_uncertainty(doc, legacy, modern_name, renames),
352
+ )
353
+ legacy.replace(univariate)
354
+ end
355
+ end
356
+
357
+ def modern_uncertainty(doc, legacy, modern_name, renames)
358
+ element(doc, "si:#{modern_name}").tap do |modern|
359
+ legacy.element_children.each do |child|
360
+ child.name = renames.fetch(child.name, child.name)
361
+ modern.add_child(child)
362
+ end
363
+ end
364
+ end
365
+
366
+ # v2.2.1 `si:list` is a pure container; a v1 body moves into one
367
+ # `si:realList` or `si:complexList`.
368
+ def wrap_list_bodies(doc)
369
+ doc.xpath("//si:list", NS).each do |list|
370
+ body = list.element_children.reject do |child|
371
+ LIST_CONTAINER_CHILDREN.include?(child.name)
372
+ end
373
+ next if body.empty?
374
+
375
+ wrapper = element(doc, "si:#{list_wrapper_name(body)}")
376
+ body.each { |child| wrapper.add_child(child) }
377
+ list.add_child(wrapper)
378
+ end
379
+ end
380
+
381
+ def list_wrapper_name(body)
382
+ if body.any? { |child| child.name == "complex" }
383
+ "complexList"
384
+ else
385
+ "realList"
386
+ end
387
+ end
388
+
389
+ # D-SI v1 accepted `.95`; v2.2.1 requires a leading zero.
390
+ def normalize_probabilities(doc)
391
+ doc.xpath("//si:coverageProbability", NS).each do |node|
392
+ text = node.text.strip
393
+ node.content = "0#{text}" if text.start_with?(".")
394
+ end
395
+ end
396
+
397
+ def collect_losses(doc, losses)
398
+ collect_formula_lang(doc, losses)
399
+ collect_extra_lab_contacts(doc, losses)
400
+ end
401
+
402
+ def collect_formula_lang(doc, losses)
403
+ doc.xpath("//dcc:formula[@lang]", NS).each do |node|
404
+ losses << "formula/@lang=#{node['lang']} " \
405
+ "(v3 formulae carry no language)"
406
+ node.remove_attribute("lang")
407
+ end
408
+ end
409
+
410
+ def collect_extra_lab_contacts(doc, losses)
411
+ doc.xpath("//dcc:calibrationLaboratory", NS).each do |lab|
412
+ extra = lab.xpath("dcc:contact", NS).to_a.drop(1)
413
+ next if extra.empty?
414
+
415
+ extra.each(&:remove)
416
+ losses << "#{extra.size} extra calibrationLaboratory/contact " \
417
+ "(v3 allows one)"
418
+ end
419
+ end
420
+
421
+ # With an `on_loss` callable the caller owns the loss list: every
422
+ # message goes to the callable, one call per loss, and nothing is
423
+ # warned. Without one, the report goes to stderr as before.
424
+ def report(losses, to, on_loss)
425
+ return if losses.empty?
426
+
427
+ if on_loss
428
+ losses.each { |loss| on_loss.call(loss) }
429
+ return
430
+ end
431
+
432
+ Kernel.warn(
433
+ "Dcc.migrate 2.3.0 to #{to} changed or discarded " \
434
+ "#{losses.size} construct(s) v3 cannot represent as written: " \
435
+ "#{losses.join('; ')}",
436
+ )
437
+ end
438
+
439
+ def element(doc, name)
440
+ ::Nokogiri::XML::Node.new(name, doc)
441
+ end
442
+ end
443
+ end
444
+ end
445
+ end
data/lib/dcc/migrate.rb CHANGED
@@ -1,41 +1,145 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # `Dcc::Migrate` upgrades or downgrades a parsed DCC between schema versions,
4
- # applying the necessary field renames and D-SI version transitions.
3
+ # `Dcc::Migrate` upgrades a DCC to a newer schema version.
5
4
  #
6
- # Currently a thin shim: serializes the source DCC and re-parses under
7
- # the target version's context. Future work will add per-field transformations.
5
+ # Migration is a document transform: the route's field rules are applied to
6
+ # the XML and the result is parsed under the target version. The input is
7
+ # never modified.
8
+ #
9
+ # Pass the source XML when you have it. Passing a parsed model instead costs
10
+ # whatever that model could not round-trip, because the model has to be
11
+ # serialised back to XML before the transform can see it — the transform
12
+ # cannot report a construct the parse already dropped.
13
+ #
14
+ # Migrations are one-way. A downgrade cannot preserve the source — v3 adds
15
+ # elements and attributes that no older schema accepts — so downgrading warns
16
+ # and then raises rather than returning a quietly invalid document.
8
17
  module Dcc
9
18
  module Migrate
19
+ autoload :Route, "dcc/migrate/route"
20
+ autoload :V2ToV3, "dcc/migrate/v2_to_v3"
21
+
10
22
  class << self
11
- # @param dcc [Lutaml::Model::Serializable]
12
- # @param from [String] source version (e.g. "2.3.0").
13
- # @param to [String] target version (e.g. "3.3.0").
14
- # @return [Dcc::V2::DigitalCalibrationCertificate, Dcc::V3::DigitalCalibrationCertificate]
15
- def call(dcc, from:, to:)
16
- from_major = ::Dcc::Schema::Version.major(from)
17
- to_major = ::Dcc::Schema::Version.major(to)
18
- if from_major == to_major
19
- # Same major: just rewrite the schemaVersion attribute.
20
- return rewrite_schema_version(dcc, to)
21
- end
23
+ # @param input [String, IO, Lutaml::Model::Serializable] DCC source XML,
24
+ # or a parsed DCC. Prefer the source XML: see the note on this module.
25
+ # @param from [String] source version, e.g. "2.3.0".
26
+ # @param to [String] target version, e.g. "3.3.0".
27
+ # @param on_loss [#call, nil] callable receiving each loss message (a
28
+ # String) the transform would otherwise send to `Kernel.warn`; when
29
+ # given, nothing is warned. Nil keeps the stderr report.
30
+ # @raise [Dcc::UnknownVersionError] if either version is not bundled.
31
+ # @raise [Dcc::UnsupportedMigrationError] on a downgrade, or on a pair
32
+ # whose incompatibilities have not been verified.
33
+ # @return [Dcc::V2::DigitalCalibrationCertificate,
34
+ # Dcc::V3::DigitalCalibrationCertificate] always a parsed DCC.
35
+ def call(input, from:, to:, on_loss: nil)
36
+ source = ::Dcc::Schema::Version.resolve_dcc(from)
37
+ target = ::Dcc::Schema::Version.resolve_dcc(to)
38
+ return same_version_result(input, target) if source == target
22
39
 
23
- # Cross-major migration: serialize then re-parse under target.
24
- xml = dcc.to_xml
25
- target_parser = ::Dcc.parser_for(to_major)
26
- target_parser.load_all!
27
- migrated = target_parser.parse(xml)
28
- rewrite_schema_version(migrated, to)
40
+ reject_downgrade(source, target)
41
+ reject_unregistered(source, target)
42
+
43
+ transform = Route.for(source, target)
44
+ xml = source_xml(input)
45
+ migrated = if transform
46
+ transform.call(xml, to: target,
47
+ on_loss: on_loss)
48
+ else
49
+ xml
50
+ end
51
+ parse_as(migrated, target)
29
52
  end
30
53
 
31
54
  private
32
55
 
33
- def rewrite_schema_version(dcc, version)
34
- return dcc unless Dcc::TypeGuards.has_writer?(dcc, :schema_version)
56
+ # A same-version call must not round-trip a model through XML: that
57
+ # would apply the parse loss this transform exists to avoid. The model
58
+ # is still checked, so the no-op path cannot hand back a non-DCC object
59
+ # the other paths would have refused.
60
+ #
61
+ # The version is checked too. Every other route re-parses under the
62
+ # target, so its result carries the requested version whatever `from:`
63
+ # said. This route returns the input untouched, so a mis-declared
64
+ # `from:` would hand back a document of some other version and make both
65
+ # parameters meaningless.
66
+ def same_version_result(input, target)
67
+ if dcc_document?(input)
68
+ reject_version_mismatch(input, target)
69
+ return input
70
+ end
71
+
72
+ parse_as(source_xml(input), target)
73
+ end
74
+
75
+ def reject_version_mismatch(dcc, target)
76
+ actual = dcc.schema_version
77
+ return if actual.nil? || actual == target
78
+
79
+ raise ::Dcc::Error,
80
+ "Dcc.migrate was asked for #{target} but the document " \
81
+ "declares #{actual}."
82
+ end
83
+
84
+ # The one place an input becomes XML, and the one place the DCC root is
85
+ # checked.
86
+ #
87
+ # The root check belongs here rather than in the transform: the
88
+ # schemaVersion-only and same-version routes never reach a transform,
89
+ # and any `Serializable` serialises happily, so both would otherwise
90
+ # turn a foreign document into an empty certificate.
91
+ def source_xml(input)
92
+ V2ToV3.assert_dcc_root(raw_xml(input))
93
+ end
94
+
95
+ # `Dcc.read_input` falls through to `to_s` for anything that is not
96
+ # IO-like, so the parsed-model case has to be answered first.
97
+ def raw_xml(input)
98
+ return input.to_xml if input.is_a?(::Lutaml::Model::Serializable)
99
+
100
+ reject_untranslatable(input)
101
+ ::Dcc.read_input(input)
102
+ end
103
+
104
+ # The two DCC root models. Checked by class rather than by serialising
105
+ # and re-reading, so the same-version no-op stays a no-op.
106
+ def dcc_document?(input)
107
+ input.is_a?(::Dcc::V2::DigitalCalibrationCertificate) ||
108
+ input.is_a?(::Dcc::V3::DigitalCalibrationCertificate)
109
+ end
110
+
111
+ def reject_untranslatable(input)
112
+ return if input.is_a?(::String) || ::Dcc.io_like?(input)
113
+
114
+ raise ::Dcc::Error,
115
+ "Dcc.migrate expects DCC source XML or a parsed DCC document, " \
116
+ "got #{input.class}."
117
+ end
118
+
119
+ def reject_downgrade(source, target)
120
+ return if ::Gem::Version.new(target) > ::Gem::Version.new(source)
121
+
122
+ message = "Cannot migrate #{source} to #{target}: downgrading " \
123
+ "discards information the older schema cannot represent."
124
+ Kernel.warn(message)
125
+ raise ::Dcc::UnsupportedMigrationError, message
126
+ end
127
+
128
+ def reject_unregistered(source, target)
129
+ return if Route.supported?(source, target)
130
+
131
+ raise ::Dcc::UnsupportedMigrationError,
132
+ "No verified migration from #{source} to #{target}. " \
133
+ "Supported: #{Route.supported_pairs.join(', ')}."
134
+ end
35
135
 
36
- dcc.schema_version = version
37
- dcc
136
+ def parse_as(xml, version)
137
+ parser = ::Dcc.parser_for(::Dcc::Schema::Version.major(version))
138
+ parser.load_all!
139
+ parsed = parser.parse(xml)
140
+ parsed.schema_version = version
141
+ parsed
38
142
  end
39
143
  end
40
144
  end
41
- end
145
+ end