dcc 0.2.1 → 0.2.3

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.
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "moxml"
4
+
5
+ module Dcc
6
+ module Streaming
7
+ # SAX handler that reconstructs each matched subtree as a standalone XML
8
+ # fragment, parses it into the version-appropriate model, yields it, and
9
+ # drops it. Only one subtree is buffered at a time, so peak memory tracks
10
+ # the largest single subtree rather than the document.
11
+ #
12
+ # The fragment reproduces the XML infoset, not the original bytes.
13
+ # Entity references arrive expanded, CDATA becomes text, processing
14
+ # instruction spacing is normalised, and every namespace in scope is
15
+ # re-declared on the fragment root so prefixes still resolve once the
16
+ # subtree is detached.
17
+ class Reader < ::Moxml::SAX::Handler
18
+ # Stream `io`, yielding one model per matched subtree.
19
+ #
20
+ # @param io [IO, StringIO] readable XML stream.
21
+ # @param model_id [Symbol] registered model id, `:item` or `:quantity`.
22
+ # @param version [Integer, nil] major DCC version. When nil it is read
23
+ # from the first `schemaVersion` attribute seen outside a matched
24
+ # subtree before the first model is yielded, and falls back to 3.
25
+ # `Dcc.detect_version` instead scans the whole document lexically,
26
+ # because it is not restricted to a single pass.
27
+ # @param context [Symbol, String, nil] substitution context.
28
+ # @return [void]
29
+ # @raise [Dcc::ParseError] if the stream is not well-formed XML. A
30
+ # failing IO also surfaces this way, with the original IO message
31
+ # replaced by the parser's — libxml2 does not pass it through.
32
+ def self.call(io, model_id:, version:, context:, &)
33
+ handler = new(model_id:, version:, context:, &)
34
+ adapter = ::Lutaml::Model::Config.xml_adapter_type
35
+ ::Moxml.new(adapter).sax_parse(io, handler)
36
+ end
37
+
38
+ # A handler carries one document's worth of state, so `.call` builds a
39
+ # fresh one per stream and construction stays closed.
40
+ private_class_method :new
41
+
42
+ def initialize(model_id:, version:, context:, &block)
43
+ super()
44
+ @model_id = model_id
45
+ @major = version
46
+ @context = context
47
+ @block = block
48
+ @scopes = [{}]
49
+ @subtree = nil
50
+ @depth = 0
51
+ end
52
+
53
+ # Declarations are merged and snapshotted before the element's own
54
+ # prefix is resolved, so an element that rebinds its prefix matches on
55
+ # its new binding rather than the inherited one.
56
+ def on_start_element(name, attributes = {}, namespaces = {})
57
+ # Almost no element declares a namespace, so merging every time would
58
+ # allocate a copy of the parent scope per element. Scopes are only
59
+ # ever read, so the unchanged ones can share one hash.
60
+ @scopes.push(
61
+ namespaces.empty? ? @scopes.last : @scopes.last.merge(namespaces),
62
+ )
63
+ return begin_subtree(name, attributes) unless @subtree
64
+
65
+ @depth += 1
66
+ @subtree.start_element(name, attributes, namespaces)
67
+ end
68
+
69
+ def on_end_element(name)
70
+ finish_element(name) if @subtree
71
+ @scopes.pop
72
+ end
73
+
74
+ def on_characters(text)
75
+ @subtree&.characters(text)
76
+ end
77
+
78
+ def on_cdata(text)
79
+ @subtree&.characters(text)
80
+ end
81
+
82
+ def on_comment(text)
83
+ @subtree&.comment(text)
84
+ end
85
+
86
+ def on_processing_instruction(target, data)
87
+ @subtree&.processing_instruction(target, data)
88
+ end
89
+
90
+ # Translated here rather than around the whole parse, so an exception
91
+ # raised by the consumer block travels untouched instead of being
92
+ # reported as malformed input.
93
+ def on_error(error)
94
+ raise ::Dcc::ParseError, error.message
95
+ end
96
+
97
+ private
98
+
99
+ # Only elements outside a matched subtree get a say in the version, so a
100
+ # `schemaVersion` on a descendant — or inside an opaque `dcc:xml` blob —
101
+ # cannot choose the parser for the whole stream.
102
+ def begin_subtree(name, attributes)
103
+ @major ||= schema_version_major(name, attributes)
104
+ return unless match?(name)
105
+
106
+ @depth = 0
107
+ @subtree = Subtree.new(name, attributes, @scopes.last)
108
+ end
109
+
110
+ def finish_element(name)
111
+ @subtree.end_element(name)
112
+ if @depth.zero?
113
+ emit(@subtree.to_xml)
114
+ @subtree = nil
115
+ else
116
+ @depth -= 1
117
+ end
118
+ end
119
+
120
+ def emit(xml)
121
+ @block.call(model_class.from_xml(xml, register: context_id))
122
+ end
123
+
124
+ def match?(name)
125
+ split(name).last == @model_id.to_s && dcc_element?(name)
126
+ end
127
+
128
+ def dcc_element?(name)
129
+ dcc_namespace?(@scopes.last[split(name).first])
130
+ end
131
+
132
+ # `Dcc::Namespace::Dcc` also accepts the `.xsd` alias that older PTB
133
+ # documents bind, so streaming matches whatever `Dcc.parse` accepts.
134
+ def dcc_namespace?(uri)
135
+ namespace = ::Dcc::Namespace::Dcc
136
+ uri == namespace.uri || namespace.uri_aliases.include?(uri)
137
+ end
138
+
139
+ def split(name)
140
+ prefix, local = name.split(":", 2)
141
+ local ? [prefix, local] : [nil, prefix]
142
+ end
143
+
144
+ # Resolved through the registry rather than referenced directly, so a
145
+ # configured custom model substitutes the root class too.
146
+ def model_class
147
+ @model_class ||=
148
+ ::Lutaml::Model::GlobalContext.resolve_type(@model_id, context_id)
149
+ end
150
+
151
+ def context_id
152
+ @context_id ||= ::Dcc::ContextOptions.normalize_context_option(
153
+ context: @context,
154
+ register: nil,
155
+ default_context: configuration.default_context_id,
156
+ warning_source: "Dcc::Streaming",
157
+ )
158
+ end
159
+
160
+ def configuration
161
+ ::Dcc.parser_for(major)::Configuration
162
+ end
163
+
164
+ # `schemaVersion` sits on the DCC root, which is not always the document
165
+ # root — a certificate can arrive wrapped in an envelope. Only a DCC
166
+ # element settles the version, so a wrapper carrying a `schemaVersion`
167
+ # of its own does not retype the certificate nested inside it.
168
+ def schema_version_major(name, attributes)
169
+ version = attributes["schemaVersion"]
170
+ return unless version && dcc_element?(name)
171
+
172
+ ::Dcc.major_version_from(version)
173
+ end
174
+
175
+ # A document that declares `schemaVersion` nowhere falls back the way an
176
+ # unusable one does, so `Dcc` keeps deciding that rather than a literal
177
+ # repeated here. The answer is written back rather than defaulted on
178
+ # each read: the model class and the context are memoised off this at
179
+ # the first yield, so a `schemaVersion` appearing later must not move
180
+ # the version out from under them.
181
+ def major
182
+ @major ||= ::Dcc.major_version_from(nil)
183
+ end
184
+
185
+ # Accumulates the raw XML of a single matched subtree.
186
+ #
187
+ # SAX hands over decoded characters, so control whitespace has to go
188
+ # back as character references. Written literally it would be
189
+ # normalised a second time on reparse — a carriage return in text
190
+ # would arrive as a newline, and a tab or newline in an attribute
191
+ # would collapse to a space.
192
+ #
193
+ # C14N mandates exactly that set of character references, so moxml's
194
+ # encoder is reused rather than restating the table here.
195
+ class Subtree
196
+ def initialize(name, attributes, scope)
197
+ @buffer = +""
198
+ write_start(name, attributes, scope)
199
+ end
200
+
201
+ def start_element(name, attributes, namespaces)
202
+ write_start(name, attributes, namespaces)
203
+ end
204
+
205
+ def end_element(name)
206
+ @buffer << "</#{name}>"
207
+ end
208
+
209
+ def characters(text)
210
+ @buffer << ::Moxml::C14n.escape_text(text)
211
+ end
212
+
213
+ def comment(text)
214
+ @buffer << "<!--#{text}-->"
215
+ end
216
+
217
+ def processing_instruction(target, data)
218
+ @buffer << "<?#{target} #{data}?>"
219
+ end
220
+
221
+ def to_xml
222
+ @buffer
223
+ end
224
+
225
+ private
226
+
227
+ def write_start(name, attributes, namespaces)
228
+ @buffer << "<#{name}"
229
+ namespaces.each do |prefix, href|
230
+ write_attribute(prefix ? "xmlns:#{prefix}" : "xmlns", href)
231
+ end
232
+ attributes.each do |key, value|
233
+ write_attribute(key, value)
234
+ end
235
+ @buffer << ">"
236
+ end
237
+
238
+ def write_attribute(name, value)
239
+ escaped = ::Moxml::C14n.escape_attribute(value)
240
+ @buffer << %( #{name}="#{escaped}")
241
+ end
242
+ end
243
+ private_constant :Subtree
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # `Dcc::Streaming` walks a DCC document one subtree at a time, so a batch
4
+ # pipeline can process a multi-MB certificate without holding the whole
5
+ # object tree in memory. Each matched subtree is reconstructed on its own,
6
+ # parsed into the version-appropriate model, yielded, and dropped.
7
+ #
8
+ # @example Walk every calibrated item
9
+ # File.open("certificate.xml") do |io|
10
+ # Dcc::Streaming.each_item(io) { |item| puts item.id }
11
+ # end
12
+ #
13
+ # @example Take the first two quantities without reading the whole document
14
+ # Dcc::Streaming.each_quantity(io).first(2)
15
+ #
16
+ # Version detection differs from `Dcc.parse`. Streaming reads `schemaVersion`
17
+ # off a real DCC element, so a certificate wrapped in an envelope is detected
18
+ # from its own root even when the envelope carries a `schemaVersion` of its
19
+ # own. `Dcc.detect_version` scans the document lexically instead, so it also
20
+ # matches a `schemaVersion=` inside a comment, inside text or CDATA, on a
21
+ # prefixed attribute such as `f:schemaVersion`, or on a foreign wrapper
22
+ # element. The two can disagree on those documents, and streaming is the one
23
+ # reading the schema's actual attribute. Pass `version:` to settle it.
24
+ module Dcc
25
+ module Streaming
26
+ autoload :Reader, "dcc/streaming/reader"
27
+
28
+ class << self
29
+ # Yield each `dcc:item` in document order.
30
+ #
31
+ # @param io [IO, StringIO] readable XML stream.
32
+ # @param version [Integer, nil] major DCC version (2 or 3). When nil it
33
+ # is detected from the first `schemaVersion` attribute seen outside a
34
+ # matched subtree before the first item is yielded, and falls back to
35
+ # 3. See the note on version detection above.
36
+ # @param context [Symbol, String, nil] substitution context. Defaults
37
+ # to the version's configured context.
38
+ # @yieldparam [Dcc::V2::Item, Dcc::V3::Item]
39
+ # @return [Enumerator] when no block is given.
40
+ # @raise [Dcc::ParseError] if the stream is not well-formed XML.
41
+ def each_item(io, version: nil,
42
+ context: ::Dcc::UNSPECIFIED_CONTEXT, &block)
43
+ return enum_for(:each_item, io, version:, context:) unless block
44
+
45
+ stream(io, :item, version, context, &block)
46
+ end
47
+
48
+ # Yield each outermost `dcc:quantity` in document order.
49
+ #
50
+ # A quantity nested inside another quantity's `influenceConditions` is
51
+ # not yielded on its own — it is reachable from the outer object.
52
+ #
53
+ # @param io [IO, StringIO] readable XML stream.
54
+ # @param version [Integer, nil] major DCC version (2 or 3). Detected the
55
+ # same way `each_item` detects it when nil.
56
+ # @param context [Symbol, String, nil] substitution context.
57
+ # @yieldparam [Dcc::V2::Quantity, Dcc::V3::Quantity]
58
+ # @return [Enumerator] when no block is given.
59
+ # @raise [Dcc::ParseError] if the stream is not well-formed XML.
60
+ def each_quantity(io, version: nil,
61
+ context: ::Dcc::UNSPECIFIED_CONTEXT, &block)
62
+ return enum_for(:each_quantity, io, version:, context:) unless block
63
+
64
+ stream(io, :quantity, version, context, &block)
65
+ end
66
+
67
+ private
68
+
69
+ # `version` is validated here rather than on first match, so an
70
+ # unsupported value raises the same way regardless of what the
71
+ # document happens to contain.
72
+ def stream(io, model_id, version, context, &)
73
+ ::Dcc.parser_for(version) if version
74
+ ::Dcc.load_all!
75
+ Reader.call(io, model_id:, version:, context:, &)
76
+ end
77
+ end
78
+ end
79
+ end
data/lib/dcc/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dcc
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.3"
5
5
  end
data/lib/dcc.rb CHANGED
@@ -41,6 +41,7 @@ module Dcc
41
41
  autoload :QuantityFormat, "dcc/quantity_format"
42
42
  autoload :QuantityMath, "dcc/quantity_math"
43
43
  autoload :Signature, "dcc/signature"
44
+ autoload :Streaming, "dcc/streaming"
44
45
  autoload :Transform, "dcc/transform"
45
46
  autoload :Validate, "dcc/validate"
46
47
  autoload :Server, "dcc/server"
@@ -151,9 +152,18 @@ module Dcc
151
152
  # @return [Integer] 2 or 3.
152
153
  def detect_version(input)
153
154
  str = read_input(input)
154
- match = str.match(/schemaVersion\s*=\s*["'](\d+)\./)
155
- major = match && match[1] ? match[1].to_i : 3
156
- major == 2 ? 2 : 3
155
+ match = str.match(/schemaVersion\s*=\s*["'](\d+\.)/)
156
+ major_version_from(match && match[1])
157
+ end
158
+
159
+ # Map a `schemaVersion` attribute value to its major DCC version.
160
+ # Only a leading run of digits followed by a dot counts; anything else
161
+ # (missing, malformed, or an unsupported major) resolves to 3.
162
+ # @param schema_version [String, nil] e.g. "2.3.0".
163
+ # @return [Integer] 2 or 3.
164
+ def major_version_from(schema_version)
165
+ match = schema_version.to_s.match(/\A(\d+)\./)
166
+ match && match[1].to_i == 2 ? 2 : 3
157
167
  end
158
168
 
159
169
  # Read an input that may be a String or an IO-like object.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dcc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-19 00:00:00.000000000 Z
11
+ date: 2026-11-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bigdecimal
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: csv
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -72,14 +86,14 @@ dependencies:
72
86
  requirements:
73
87
  - - "~>"
74
88
  - !ruby/object:Gem::Version
75
- version: 0.8.114
89
+ version: 0.8.156
76
90
  type: :runtime
77
91
  prerelease: false
78
92
  version_requirements: !ruby/object:Gem::Requirement
79
93
  requirements:
80
94
  - - "~>"
81
95
  - !ruby/object:Gem::Version
82
- version: 0.8.114
96
+ version: 0.8.156
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: mml
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -100,14 +114,14 @@ dependencies:
100
114
  requirements:
101
115
  - - "~>"
102
116
  - !ruby/object:Gem::Version
103
- version: '0.5'
117
+ version: 0.6.0
104
118
  type: :runtime
105
119
  prerelease: false
106
120
  version_requirements: !ruby/object:Gem::Requirement
107
121
  requirements:
108
122
  - - "~>"
109
123
  - !ruby/object:Gem::Version
110
- version: '0.5'
124
+ version: 0.6.0
111
125
  - !ruby/object:Gem::Dependency
112
126
  name: nokogiri
113
127
  requirement: !ruby/object:Gem::Requirement
@@ -240,6 +254,11 @@ files:
240
254
  - lib/dcc/error.rb
241
255
  - lib/dcc/extract.rb
242
256
  - lib/dcc/extract/file.rb
257
+ - lib/dcc/extract/formula.rb
258
+ - lib/dcc/extract/formula/ast.rb
259
+ - lib/dcc/extract/formula/evaluator.rb
260
+ - lib/dcc/extract/formula/parser.rb
261
+ - lib/dcc/extract/formula/quantity.rb
243
262
  - lib/dcc/extract/ring.rb
244
263
  - lib/dcc/i18n.rb
245
264
  - lib/dcc/i18n/text_lookup.rb
@@ -260,6 +279,8 @@ files:
260
279
  - lib/dcc/quantity_format.rb
261
280
  - lib/dcc/quantity_format/formatter.rb
262
281
  - lib/dcc/quantity_math.rb
282
+ - lib/dcc/quantity_math/complex.rb
283
+ - lib/dcc/quantity_math/matrix.rb
263
284
  - lib/dcc/quantity_math/quantity.rb
264
285
  - lib/dcc/quantity_math/real.rb
265
286
  - lib/dcc/schema.rb
@@ -363,6 +384,8 @@ files:
363
384
  - lib/dcc/signature/result.rb
364
385
  - lib/dcc/signature/signer.rb
365
386
  - lib/dcc/signature/verifier.rb
387
+ - lib/dcc/streaming.rb
388
+ - lib/dcc/streaming/reader.rb
366
389
  - lib/dcc/transform.rb
367
390
  - lib/dcc/transform/result.rb
368
391
  - lib/dcc/transform/xslt.rb