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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +19 -0
- data/.github/workflows/release.yml +28 -0
- data/.gitignore +4 -0
- data/.rubocop_todo.yml +560 -10
- data/CHANGELOG.adoc +34 -1
- data/CONTRIBUTING.adoc +8 -1
- data/Gemfile +5 -7
- data/README.adoc +162 -7
- data/dcc.gemspec +8 -2
- data/docs/schema-versions.adoc +20 -7
- data/examples/build_minimal_dcc.rb +0 -0
- data/examples/convert_all_formats.rb +2 -1
- data/examples/parse_and_inspect.rb +0 -0
- data/examples/quantity_math_demo.rb +7 -7
- data/examples/validate_full.rb +1 -1
- data/lib/dcc/base/calibration_laboratory.rb +2 -1
- data/lib/dcc/base/condition.rb +5 -2
- data/lib/dcc/base/core_data.rb +8 -3
- data/lib/dcc/base/formula.rb +3 -3
- data/lib/dcc/base/identification.rb +6 -3
- data/lib/dcc/base/mathml.rb +33 -0
- data/lib/dcc/base/quantity.rb +2 -1
- data/lib/dcc/base/resp_person.rb +4 -2
- data/lib/dcc/base/text.rb +1 -1
- data/lib/dcc/base.rb +2 -1
- data/lib/dcc/builder/dsl.rb +1 -1
- data/lib/dcc/builder/session.rb +16 -14
- data/lib/dcc/builder.rb +3 -3
- data/lib/dcc/cli/cli.rb +36 -18
- data/lib/dcc/cli/formatters.rb +35 -7
- data/lib/dcc/cli.rb +1 -1
- data/lib/dcc/context_configuration.rb +2 -1
- data/lib/dcc/context_options.rb +4 -2
- data/lib/dcc/convert/csv.rb +27 -9
- data/lib/dcc/convert/html.rb +26 -110
- data/lib/dcc/convert/json.rb +10 -7
- data/lib/dcc/convert/liquid_filters.rb +58 -0
- data/lib/dcc/convert/result.rb +1 -1
- data/lib/dcc/convert/templates/report.liquid +250 -0
- data/lib/dcc/convert/yaml.rb +1 -1
- data/lib/dcc/convert.rb +1 -0
- data/lib/dcc/diff/change.rb +1 -1
- data/lib/dcc/diff/result.rb +8 -4
- data/lib/dcc/diff.rb +43 -11
- data/lib/dcc/error.rb +7 -0
- data/lib/dcc/extract/file.rb +3 -4
- data/lib/dcc/extract/ring.rb +1 -1
- data/lib/dcc/extract.rb +1 -1
- data/lib/dcc/i18n/text_lookup.rb +8 -4
- data/lib/dcc/i18n.rb +1 -1
- data/lib/dcc/inspect/report.rb +51 -14
- data/lib/dcc/inspect.rb +1 -1
- data/lib/dcc/migrate/route.rb +44 -0
- data/lib/dcc/migrate/v2_to_v3.rb +445 -0
- data/lib/dcc/migrate.rb +130 -26
- data/lib/dcc/plugin/base.rb +64 -0
- data/lib/dcc/plugin/registry.rb +7 -2
- data/lib/dcc/plugin.rb +6 -2
- data/lib/dcc/quantity_format/formatter.rb +16 -10
- data/lib/dcc/quantity_format.rb +1 -1
- data/lib/dcc/quantity_math/quantity.rb +1 -1
- data/lib/dcc/quantity_math/real.rb +6 -6
- data/lib/dcc/quantity_math.rb +1 -1
- data/lib/dcc/schema/version.rb +2 -2
- data/lib/dcc/server/app.html +282 -0
- data/lib/dcc/server/app.rb +171 -87
- data/lib/dcc/server/demo_cert.xml +244 -0
- data/lib/dcc/server/storage/entry.rb +1 -1
- data/lib/dcc/server/storage/memory.rb +3 -2
- data/lib/dcc/server/storage.rb +1 -1
- data/lib/dcc/server.rb +1 -1
- data/lib/dcc/si/base/complex.rb +1 -1
- data/lib/dcc/si/base/complex_list_xml_list.rb +1 -1
- data/lib/dcc/si/base/constant.rb +1 -1
- data/lib/dcc/si/base/coverage_interval.rb +36 -0
- data/lib/dcc/si/base/coverage_interval_mu.rb +1 -1
- data/lib/dcc/si/base/expanded_mu.rb +1 -1
- data/lib/dcc/si/base/expanded_unc.rb +34 -0
- data/lib/dcc/si/base/expanded_unc_xml_list.rb +3 -2
- data/lib/dcc/si/base/hybrid.rb +3 -2
- data/lib/dcc/si/base/measurement_uncertainty_univariate.rb +1 -1
- data/lib/dcc/si/base/real.rb +13 -3
- data/lib/dcc/si/base/real_list_xml_list.rb +5 -3
- data/lib/dcc/si/base/si_list.rb +3 -2
- data/lib/dcc/si/base/standard_mu.rb +1 -1
- data/lib/dcc/si/base.rb +5 -2
- data/lib/dcc/si/v1/coverage_interval.rb +8 -0
- data/lib/dcc/si/v1/expanded_unc.rb +1 -16
- data/lib/dcc/si/v1/measurement_uncertainty_univariate.rb +2 -1
- data/lib/dcc/si/v1.rb +5 -2
- data/lib/dcc/si/v2/coverage_interval.rb +8 -0
- data/lib/dcc/si/v2/coverage_interval_mu_xml_list.rb +4 -2
- data/lib/dcc/si/v2/expanded_mu_xml_list.rb +2 -1
- data/lib/dcc/si/v2/expanded_unc.rb +8 -0
- data/lib/dcc/si/v2/measurement_uncertainty_univariate.rb +2 -1
- data/lib/dcc/si/v2.rb +8 -3
- data/lib/dcc/signature/result.rb +2 -2
- data/lib/dcc/signature/signer.rb +31 -18
- data/lib/dcc/signature/verifier.rb +23 -21
- data/lib/dcc/signature.rb +1 -1
- data/lib/dcc/transform/result.rb +1 -1
- data/lib/dcc/transform/xslt.rb +22 -12
- data/lib/dcc/transform.rb +5 -4
- data/lib/dcc/type/base64_binary.rb +5 -3
- data/lib/dcc/type/decimal_xml_list.rb +99 -13
- data/lib/dcc/type/iso_country_code.rb +3 -2
- data/lib/dcc/type/iso_language_code.rb +3 -2
- data/lib/dcc/type/schema_version.rb +3 -2
- data/lib/dcc/type/si_unit.rb +3 -2
- data/lib/dcc/type_guards.rb +3 -3
- data/lib/dcc/v2/calibration_laboratory.rb +2 -1
- data/lib/dcc/v2/condition.rb +22 -1
- data/lib/dcc/v2/configuration.rb +1 -1
- data/lib/dcc/v2/digital_calibration_certificate.rb +2 -1
- data/lib/dcc/v2/identification.rb +14 -1
- data/lib/dcc/v2/mathml.rb +8 -0
- data/lib/dcc/v2/measurement_meta_data_list.rb +2 -1
- data/lib/dcc/v2/quantity.rb +4 -2
- data/lib/dcc/v2.rb +4 -2
- data/lib/dcc/v3/calibration_laboratory.rb +2 -1
- data/lib/dcc/v3/configuration.rb +1 -1
- data/lib/dcc/v3/digital_calibration_certificate.rb +2 -1
- data/lib/dcc/v3/mathml.rb +8 -0
- data/lib/dcc/v3/measurement_meta_data_list.rb +2 -1
- data/lib/dcc/v3/quantity.rb +4 -2
- data/lib/dcc/v3.rb +12 -2
- data/lib/dcc/validate/business_rules.rb +23 -9
- data/lib/dcc/validate/issue.rb +2 -1
- data/lib/dcc/validate/result.rb +6 -4
- data/lib/dcc/validate/schematron/profile.rb +73 -18
- data/lib/dcc/validate/schematron/rule.rb +13 -3
- data/lib/dcc/validate/schematron/rules/administrative_data_completeness.rb +11 -4
- data/lib/dcc/validate/schematron/rules/base.rb +1 -2
- data/lib/dcc/validate/schematron/rules/date_range_check.rb +11 -5
- data/lib/dcc/validate/schematron/rules/id_refid_linking.rb +0 -3
- data/lib/dcc/validate/schematron/rules/influence_conditions_placement.rb +22 -12
- data/lib/dcc/validate/schematron/rules/iso_code_validation.rb +31 -14
- data/lib/dcc/validate/schematron/rules/language_code_dedup.rb +12 -5
- data/lib/dcc/validate/schematron/rules/non_si_declaration.rb +7 -4
- data/lib/dcc/validate/schematron/rules/release_format_check.rb +7 -4
- data/lib/dcc/validate/schematron/rules/schema_version_check.rb +13 -9
- data/lib/dcc/validate/schematron/rules/uncertainty_consistency.rb +39 -10
- data/lib/dcc/validate/schematron/rules/unit_format_check.rb +2 -3
- data/lib/dcc/validate/schematron/rules/used_methods_placement.rb +15 -10
- data/lib/dcc/validate/schematron/rules/used_software_placement.rb +15 -10
- data/lib/dcc/validate/schematron/rules/xml_list_spacing.rb +4 -3
- data/lib/dcc/validate/schematron/rules.rb +28 -14
- data/lib/dcc/validate/schematron.rb +1 -1
- data/lib/dcc/validate/xsd.rb +9 -3
- data/lib/dcc/validate.rb +1 -1
- data/lib/dcc/version.rb +1 -1
- data/lib/dcc/versioned_parser.rb +1 -1
- data/lib/dcc.rb +55 -7
- data/sample_dcc.xml +140 -0
- data/web/index.html +12 -0
- data/web/package-lock.json +1599 -0
- data/web/package.json +22 -0
- data/web/src/App.vue +349 -0
- data/web/src/lib/builder.ts +171 -0
- data/web/src/lib/dcc.ts +338 -0
- data/web/src/lib/units.json +5581 -0
- data/web/src/main.ts +5 -0
- data/web/src/style.css +27 -0
- data/web/tsconfig.json +15 -0
- data/web/vite.config.ts +14 -0
- 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
|
|
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
|
-
#
|
|
7
|
-
# the
|
|
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
|
|
12
|
-
#
|
|
13
|
-
# @param
|
|
14
|
-
# @
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|