Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
129 changes: 103 additions & 26 deletions lib/dcc/migrate.rb
Original file line number Diff line number Diff line change
@@ -1,40 +1,117 @@
# 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

# 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)
# @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

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.
def same_version_result(input, target)
return input if dcc_document?(input)

parse_as(source_xml(input), target)
end
Comment thread
HassanAkbar marked this conversation as resolved.

# 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
43 changes: 43 additions & 0 deletions lib/dcc/migrate/route.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

module Dcc
module Migrate
# Registry of migrations whose full incompatibility set has been verified
# against the bundled XSDs.
#
# Unregistered pairs raise rather than run. DCC 2.1.0 and 2.1.1 sit in the
# `https://ptb.de/si/smartcom/d-si/v1_0_1` D-SI namespace, which no
# transform here addresses, so accepting them by major version alone would
# silently emit an unmigrated document.
module Route
TRANSFORMS = { %w[2.3.0 3.3.0] => :V2ToV3 }.freeze

# v3.3.0 is a strict superset of v3.2.1 at instance level; only the
# `schemaVersion` pattern differs.
SCHEMA_VERSION_ONLY = [%w[3.2.1 3.3.0]].freeze

class << self
# @param from [String] normalized source version, e.g. "2.3.0".
# @param to [String] normalized target version, e.g. "3.3.0".
# @return [Boolean] whether this migration is registered.
def supported?(from, to)
TRANSFORMS.key?([from, to]) ||
SCHEMA_VERSION_ONLY.include?([from, to])
end

# @return [Module, nil] a module responding to `.call(xml, to:)`, or
# nil when only `schemaVersion` needs rewriting.
def for(from, to)
name = TRANSFORMS[[from, to]]
name && ::Dcc::Migrate.const_get(name)
end

# @return [Array<String>] every registered pair, for error messages.
def supported_pairs
(TRANSFORMS.keys + SCHEMA_VERSION_ONLY)
.map { |from, to| "#{from} to #{to}" }
end
end
end
end
end
Loading
Loading