Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ RSpec/MultipleExpectations:
- 'spec/dcc/extract/file_spec.rb'
- 'spec/dcc/i18n/text_lookup_spec.rb'
- 'spec/dcc/inspect/report_spec.rb'
- 'spec/dcc/migrate_spec.rb'
- 'spec/dcc/namespace_spec.rb'
- 'spec/dcc/plugin_spec.rb'
- 'spec/dcc/quantity_format/formatter_spec.rb'
Expand All @@ -398,6 +399,7 @@ RSpec/ExampleLength:
- 'spec/dcc/cli/formatters_spec.rb'
- 'spec/dcc/convert/additional_spec.rb'
- 'spec/dcc/extract/file_spec.rb'
- 'spec/dcc/migrate_spec.rb'
- 'spec/dcc/type_spec.rb'
- 'spec/dcc/validate/business_rules_spec.rb'
- 'spec/dcc/validate_spec.rb'
Expand Down
5 changes: 4 additions & 1 deletion CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Version
* No `require "dcc/..."` with internal paths.
* No `send` / `instance_variable_set` / `instance_variable_get`.
* No `respond_to?` for type checking.
* No Nokogiri outside `lib/dcc/validate/xsd.rb`.
* No Nokogiri outside `lib/dcc/validate/xsd.rb`. The v2-to-v3 migration later
added `lib/dcc/migrate/v2_to_v3.rb` as a second sanctioned file, because a
version transform has to rewrite documents the object model cannot
round-trip. To be removed once the parser round-trips without loss.
* No doubles in specs.
* `frozen_string_literal: true` everywhere.
* OCP/MECE/DRY throughout.
9 changes: 8 additions & 1 deletion CONTRIBUTING.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ constraints:
* `send` to call private methods — redesign the API boundary.
* `instance_variable_set` / `instance_variable_get` — use accessors.
* `respond_to?` for type checking — use `is_a?` or model the type hierarchy.
* Nokogiri anywhere outside `lib/dcc/validate/xsd.rb`.
* Nokogiri anywhere outside `lib/dcc/validate/xsd.rb` and
`lib/dcc/migrate/v2_to_v3.rb`.
+
The migration exception exists because a version transform rewrites element
names and moves subtrees on documents the object model cannot round-trip —
doing it through the model would discard the very constructs the transform
has to carry. Revisit and remove this exception when the parser round-trips
a DCC without loss, at which point the transform can run on the model.
* Doubles in specs — use real model instances.
* Hand-rolled `to_h`/`from_h` on model classes — use lutaml-model mappings.

Expand Down
13 changes: 8 additions & 5 deletions TODO.complete/28-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
**Status:** PARTIAL

## Gaps
- `lib/dcc/migrate/` does not exist: `route.rb`, `v2_to_v3.rb` and
`v3_to_v3_4.rb` are all absent.
- `lib/dcc/migrate.rb` is a 41-line shim that serializes, re-parses under the
target context and rewrites `schemaVersion`. There are no per-field
transformations and no D-SI v1 → v2 transition.
- `lib/dcc/migrate/v3_to_v3_4.rb` is absent, and there is no migration to
v3.4.0-rc.2. That version is not bundled — `Schema::Version::DCC_ALL` stops
at `3.3.0` and `resolve_dcc("3.4.0-rc.2")` raises `UnknownVersionError`.
- Only `2.3.0 → 3.3.0` has a real transform. `Route.supported_pairs` also
lists `3.2.1 → 3.3.0`, which only rewrites `schemaVersion`; every other
pair raises. DCC 2.1.0 and 2.1.1 import `dsi/v1.0.1.xsd`, whose
targetNamespace is `https://intranet.ptb.de/...`, and nothing rewrites it,
so those versions are refused rather than migrated.

## Goal
`Dcc.migrate(dcc, from:, to:)` upgrades or downgrades a DCC between schema versions, applying the necessary field renames and D-SI version transitions.
Expand Down
1 change: 1 addition & 0 deletions dcc.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@ Gem::Specification.new do |spec|
spec.add_dependency "lutaml-model", "~> 0.8"
spec.add_dependency "mml", "~> 2.4.0"
spec.add_dependency "moxml", "~> 0.1.26"
spec.add_dependency "nokogiri"
spec.add_dependency "thor"
end
27 changes: 20 additions & 7 deletions docs/schema-versions.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,28 @@ Dcc::Schema::Version.resolve_dcc(:auto, xml: xml) # => "3.3.0"

== Migration

Use `Dcc.migrate` to upgrade or downgrade between schema versions:
Use `Dcc.migrate` to upgrade a DCC to a newer schema version:

[source,ruby]
----
dcc = Dcc.parse(File.read("v3.xml"))
migrated = Dcc.migrate(dcc, from: "3.3.0", to: "3.2.1")
puts migrated.schema_version # => 3.2.1
xml = File.read("v2.xml")
migrated = Dcc.migrate(xml, from: "2.3.0", to: "3.3.0")
puts migrated.schema_version # => 3.3.0
----

Cross-major migration (e.g. v2 → v3) serializes the source DCC and
re-parses it under the target version's context. Same-major just
rewrites the `schemaVersion` attribute in place.
Pass the source XML whenever you have it. `Dcc.migrate` also accepts a
parsed DCC, but a model has to be serialized back to XML before the
transform runs — so anything the parser could not round-trip is already
gone, and the migration can neither carry it nor report it.

Migration is one-way. Downgrading raises `Dcc::UnsupportedMigrationError`,
because v3 adds elements and attributes no older schema accepts:

[source,ruby]
----
Dcc.migrate(xml, from: "3.3.0", to: "3.2.1") # raises
----

Cross-major migration (e.g. v2 → v3) applies the route's field rules to the
XML and parses the result under the target version's context. Same-major
just rewrites the `schemaVersion` attribute.
16 changes: 12 additions & 4 deletions lib/dcc.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,20 @@ def build(version: 3, &)
Builder.call(version: version, &)
end

# Migrate a parsed DCC object from one schema version to another.
# @param dcc [Dcc::V2::DigitalCalibrationCertificate, Dcc::V3::DigitalCalibrationCertificate]
# Migrate a DCC from one schema version to another.
#
# Prefer passing the source XML. A parsed model has to be serialized back
# to XML before the transform runs, so anything the model could not
# round-trip is already gone and cannot be migrated or reported.
#
# @param input [String, IO, Dcc::V2::DigitalCalibrationCertificate,
# Dcc::V3::DigitalCalibrationCertificate] DCC source XML, or a parsed DCC.
# @param from [String] source version, e.g. "2.3.0".
# @param to [String] target version, e.g. "3.3.0".
def migrate(dcc, from:, to:)
Migrate.call(dcc, from: from, to: to)
# @return [Dcc::V2::DigitalCalibrationCertificate,
# Dcc::V3::DigitalCalibrationCertificate] always a parsed DCC.
def migrate(input, from:, to:)
Migrate.call(input, from: from, to: to)
end

# Return the parser module for the given major version.
Expand Down
7 changes: 5 additions & 2 deletions lib/dcc/base/condition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
module Dcc
module Base
# `dcc:conditionType` — single influence condition: name + description +
# optional state enum (beforeAdjustment, etc.) + data.
# optional status enum (beforeAdjustment, etc.) + data.
#
# v3 calls that child `status`; v2 calls it `state`, which
# `Dcc::V2::Condition` maps.
module Condition
def self.included(klass)
klass.class_eval do
Expand All @@ -24,7 +27,7 @@ def self.included(klass)
map_attribute "refType", to: :ref_type
map_element "name", to: :name
map_element "description", to: :description
map_element "state", to: :status
map_element "status", to: :status
map_element "data", to: :data
end
end
Expand Down
2 changes: 2 additions & 0 deletions lib/dcc/base/core_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def self.included(klass)
attribute :receipt_date, :date
attribute :begin_performance_date, :date
attribute :end_performance_date, :date
attribute :performance_location, :string
attribute :previous_report, :previousReport

xml do
Expand All @@ -38,6 +39,7 @@ def self.included(klass)
map_element "receiptDate", to: :receipt_date
map_element "beginPerformanceDate", to: :begin_performance_date
map_element "endPerformanceDate", to: :end_performance_date
map_element "performanceLocation", to: :performance_location
map_element "previousReport", to: :previous_report
end
end
Expand Down
9 changes: 6 additions & 3 deletions lib/dcc/base/identification.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,25 @@
module Dcc
module Base
# `dcc:identificationType` — single identifier (issuer, value, optional
# description). Issuer enum: manufacturer, calibrationLaboratory, customer,
# name). Issuer enum: manufacturer, calibrationLaboratory, customer,
# owner, other.
#
# v3 calls the third child `name`; v2 calls it `description`, which
# `Dcc::V2::Identification` maps.
module Identification
def self.included(klass)
klass.class_eval do
attribute :issuer, :string
attribute :value, :string
attribute :description, :text
attribute :name, :text

xml do
namespace ::Dcc::Namespace::Dcc
element "identification"
ordered
map_element "issuer", to: :issuer
map_element "value", to: :value
map_element "description", to: :description
map_element "name", to: :name
end
end
end
Expand Down
4 changes: 4 additions & 0 deletions lib/dcc/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ class TransformError < Error; end
# Raised when an unsupported schema version is requested.
class UnknownVersionError < Error; end

# Raised when a migration direction is not supported — any downgrade, and
# any pair whose incompatibilities have not been verified.
class UnsupportedMigrationError < Error; end

# Raised when the builder DSL is used incorrectly.
class BuilderError < Error; end

Expand Down
145 changes: 120 additions & 25 deletions lib/dcc/migrate.rb
Original file line number Diff line number Diff line change
@@ -1,40 +1,135 @@
# frozen_string_literal: true

# `Dcc::Migrate` upgrades or downgrades a parsed DCC between schema versions,
# applying the necessary field renames and D-SI version transitions.
# `Dcc::Migrate` upgrades a DCC to a newer schema version.
#
# Currently a thin shim: serializes the source DCC and re-parses under
# the target version's context. Future work will add per-field transformations.
# Migration is a document transform: the route's field rules are applied to
# the XML and the result is parsed under the target version. The input is
# never modified.
#
# Pass the source XML when you have it. Passing a parsed model instead costs
# whatever that model could not round-trip, because the model has to be
# serialised back to XML before the transform can see it — the transform
# cannot report a construct the parse already dropped.
#
# Migrations are one-way. A downgrade cannot preserve the source — v3 adds
# elements and attributes that no older schema accepts — so downgrading warns
# and then raises rather than returning a quietly invalid document.
module Dcc
module Migrate
autoload :Route, "dcc/migrate/route"
autoload :V2ToV3, "dcc/migrate/v2_to_v3"

class << self
# @param dcc [Lutaml::Model::Serializable]
# @param from [String] source version (e.g. "2.3.0").
# @param to [String] target version (e.g. "3.3.0").
# @return [Dcc::V2::DigitalCalibrationCertificate, Dcc::V3::DigitalCalibrationCertificate]
def call(dcc, from:, to:)
from_major = ::Dcc::Schema::Version.major(from)
to_major = ::Dcc::Schema::Version.major(to)
if from_major == to_major
# Same major: just rewrite the schemaVersion attribute.
return rewrite_schema_version(dcc, to)
end
# @param input [String, IO, Lutaml::Model::Serializable] DCC source XML,
# or a parsed DCC. Prefer the source XML: see the note on this module.
# @param from [String] source version, e.g. "2.3.0".
# @param to [String] target version, e.g. "3.3.0".
# @raise [Dcc::UnknownVersionError] if either version is not bundled.
# @raise [Dcc::UnsupportedMigrationError] on a downgrade, or on a pair
# whose incompatibilities have not been verified.
# @return [Dcc::V2::DigitalCalibrationCertificate,
# Dcc::V3::DigitalCalibrationCertificate] always a parsed DCC.
def call(input, from:, to:)
source = ::Dcc::Schema::Version.resolve_dcc(from)
target = ::Dcc::Schema::Version.resolve_dcc(to)
return same_version_result(input, target) if source == target

# Cross-major migration: serialize then re-parse under target.
xml = dcc.to_xml
target_parser = ::Dcc.parser_for(to_major)
target_parser.load_all!
migrated = target_parser.parse(xml)
rewrite_schema_version(migrated, to)
reject_downgrade(source, target)
reject_unregistered(source, target)

transform = Route.for(source, target)
xml = source_xml(input)
parse_as(transform ? transform.call(xml, to: target) : xml, target)
end

private

def rewrite_schema_version(dcc, version)
return dcc unless Dcc::TypeGuards.has_writer?(dcc, :schema_version)
# A same-version call must not round-trip a model through XML: that
# would apply the parse loss this transform exists to avoid. The model
# is still checked, so the no-op path cannot hand back a non-DCC object
# the other paths would have refused.
#
# The version is checked too. Every other route re-parses under the
# target, so its result carries the requested version whatever `from:`
# said. This route returns the input untouched, so a mis-declared
# `from:` would hand back a document of some other version and make both
# parameters meaningless.
def same_version_result(input, target)
if dcc_document?(input)
reject_version_mismatch(input, target)
return input
end

parse_as(source_xml(input), target)
end

def reject_version_mismatch(dcc, target)
actual = dcc.schema_version
return if actual.nil? || actual == target

raise ::Dcc::Error,
"Dcc.migrate was asked for #{target} but the document " \
"declares #{actual}."
end

# The one place an input becomes XML, and the one place the DCC root is
# checked.
#
# The root check belongs here rather than in the transform: the
# schemaVersion-only and same-version routes never reach a transform,
# and any `Serializable` serialises happily, so both would otherwise
# turn a foreign document into an empty certificate.
def source_xml(input)
V2ToV3.assert_dcc_root(raw_xml(input))
end

# `Dcc.read_input` falls through to `to_s` for anything that is not
# IO-like, so the parsed-model case has to be answered first.
def raw_xml(input)
return input.to_xml if input.is_a?(::Lutaml::Model::Serializable)

reject_untranslatable(input)
::Dcc.read_input(input)
end

# The two DCC root models. Checked by class rather than by serialising
# and re-reading, so the same-version no-op stays a no-op.
def dcc_document?(input)
input.is_a?(::Dcc::V2::DigitalCalibrationCertificate) ||
input.is_a?(::Dcc::V3::DigitalCalibrationCertificate)
end

def reject_untranslatable(input)
return if input.is_a?(::String) || ::Dcc.io_like?(input)

raise ::Dcc::Error,
"Dcc.migrate expects DCC source XML or a parsed DCC document, " \
"got #{input.class}."
end

def reject_downgrade(source, target)
return if ::Gem::Version.new(target) > ::Gem::Version.new(source)

message = "Cannot migrate #{source} to #{target}: downgrading " \
"discards information the older schema cannot represent."
Kernel.warn(message)
raise ::Dcc::UnsupportedMigrationError, message
end

def reject_unregistered(source, target)
return if Route.supported?(source, target)

raise ::Dcc::UnsupportedMigrationError,
"No verified migration from #{source} to #{target}. " \
"Supported: #{Route.supported_pairs.join(', ')}."
end

dcc.schema_version = version
dcc
def parse_as(xml, version)
parser = ::Dcc.parser_for(::Dcc::Schema::Version.major(version))
parser.load_all!
parsed = parser.parse(xml)
parsed.schema_version = version
parsed
end
end
end
Expand Down
Loading
Loading