moxml 0.5.59 → 0.5.60

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0093cdce010f7f3dbc901b0c202b524f6df10b821fd8ed07e290688c22957ab1'
4
- data.tar.gz: bffcd7f1fc07278194510a2a1cc427bf19917028643b210380bd3f37f831f22d
3
+ metadata.gz: 70fbb27b6a314c772f06699958b27c017a5033c33a62f84ff98d15c3c8a866a0
4
+ data.tar.gz: 4222b8eb5f128d74bc55a3284596f11bc975684eae28ff3f0cb8fe56bb2a4899
5
5
  SHA512:
6
- metadata.gz: 47fab72bf502121e85e39358ffe615618af913cf5a3535c014ad2a614d0c910102b0c763c31a3d0ab4f0032bc2af407ce11da511fed05725d1fe0e2e53591ccf
7
- data.tar.gz: dca3fd95a60a848a34cc880b5173a8f98b0f24f17b7be71e5a7eff00f92add6c807b44fa7525e70d7488f13ba8e037bf5f8b9e84fb26d9c7ddf9c02cc7ed8f10
6
+ metadata.gz: '08307faaef4bfdfbda347f2f860f71f4aedabfc1b30bf81bcdfe876296ee68df6138e42efcf92c04cb94f3696a8ff33b038ce40bf826ad87491a1ee07364e2c3'
7
+ data.tar.gz: 1f084671c8983ed53172652e6322670b09550758b76378b30905bf1c1c30e7cc531d9fdd46edc3ecd7d9bf2ffa6cbd23e79a989a9d0237eae020191265c29641
@@ -358,6 +358,15 @@ namespace_validation_mode: :strict)
358
358
  nil
359
359
  end
360
360
 
361
+ # Plan row stream (Moxml::Plan): yields |name, attrs_pairs
362
+ # (flat [k, v, ...]), first-text, depth| per element in
363
+ # document order (pre-order). Returns true when the adapter
364
+ # provided the stream; nil/false lets the plan run its
365
+ # generic wrapper walk.
366
+ def plan_rows(_native)
367
+ nil
368
+ end
369
+
361
370
  # Deterministic native-memory release for adapters backed by
362
371
  # C trees (issue #134). GC-managed engines no-op; released
363
372
  # documents raise the engine's use-after-free error on
@@ -493,6 +493,50 @@ module Moxml
493
493
  extend Markers
494
494
  extend Materialize
495
495
 
496
+ # Plan row stream (Moxml::Plan): the engine's one-pass C
497
+ # snapshot is pre-order with the first text child on the row —
498
+ # exactly the plan's shape, with no wrapper minting at all.
499
+ # Marker-bearing documents stay on the generic path (the bulk
500
+ # stream has no marker split).
501
+ NATIVE_PLAN_ROWS =
502
+ NATIVE_READ_LAYER &&
503
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.193.4")
504
+
505
+ def self.plan_rows(native)
506
+ return nil unless NATIVE_READ_LAYER
507
+
508
+ doc = if native.is_a?(::Leptris::XML::Document)
509
+ native
510
+ else
511
+ doc_for(native)
512
+ end
513
+ return nil if doc.nil? || attachments.get(doc, :entity_markers)
514
+
515
+ root_binding = if native.is_a?(::Leptris::XML::Document)
516
+ doc.root
517
+ else
518
+ to_binding(native)
519
+ end
520
+ return nil unless root_binding
521
+
522
+ rows = if NATIVE_PLAN_ROWS
523
+ ::Leptris::XML::Native.snapshot_rows(doc,
524
+ root_binding.c_address)
525
+ else
526
+ doc.snapshot(root_binding)
527
+ end
528
+ rows.each do |row|
529
+ if row.is_a?(::Array)
530
+ yield(row[0], row[1], row[2], row[3])
531
+ else
532
+ next unless row[:kind] == "element"
533
+
534
+ yield(row[:name], row[:attrs].flatten, row[:text], row[:depth])
535
+ end
536
+ end
537
+ true
538
+ end
539
+
496
540
  class << self
497
541
  def attachments
498
542
  @attachments ||= Moxml::NativeAttachment.new
data/lib/moxml/plan.rb ADDED
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ # Plan-compiled materialization: declare the document shape once,
5
+ # execute it against the adapter's bulk row stream — no wrapper
6
+ # tree, no NodeSet, no per-node contract dispatch. The compiled
7
+ # grammar instead of the interpreted walk.
8
+ #
9
+ # plan = Moxml::Plan.new do
10
+ # on("record") { |attrs, _text, fields| Record.new(attrs["id"], attrs["kind"], fields) }
11
+ # on("field") { |attrs, text, _kids| Field.new(attrs["name"], attrs["unit"], text) }
12
+ # end
13
+ # records = plan.parse(xml, ctx)
14
+ #
15
+ # Handlers run bottom-up as each element completes; +children+ is
16
+ # the array of handler values for matched child elements whose
17
+ # parent ALSO matched (values of unmatched parents are dropped —
18
+ # matching a parent is the consumer's decision to keep a subtree).
19
+ # +attrs+ is a Hash of the element's attributes (local name =>
20
+ # value); +text+ is the first text child's content, or nil.
21
+ #
22
+ # Name matching is global (flat table, not path-relative): element
23
+ # names unique per role — the dominant consumer shape — work as-is;
24
+ # distinct roles sharing a name need distinct documents or a
25
+ # rename upstream.
26
+ #
27
+ # Adapters with a bulk stream answer plan_rows (leptris: the
28
+ # engine's one-pass C snapshot — pre-order, first-text on the
29
+ # row); the rest run the generic pre-order wrapper walk below,
30
+ # emitting the identical stream — spec-pinned equal.
31
+ class Plan
32
+ def initialize(&block)
33
+ @handlers = {}
34
+ instance_eval(&block) if block
35
+ end
36
+
37
+ # Registers a handler for elements named +name+ (local name).
38
+ # Returns self so registrations chain.
39
+ def on(name, &handler)
40
+ raise ArgumentError, "on(#{name.inspect}) requires a block" unless handler
41
+
42
+ @handlers[name] = handler
43
+ self
44
+ end
45
+
46
+ # Parses +xml+ and materializes the plan against it.
47
+ def parse(xml, context)
48
+ materialize(context.parse(xml))
49
+ end
50
+
51
+ # Materializes the plan against a document (or element) wrapper.
52
+ # Returns the values of top-level matched elements.
53
+ def materialize(node)
54
+ results = []
55
+ frames = [] # per open depth: children values (nil = unmatched)
56
+ meta = [] # per open depth: [name, attrs, text] (nil = unmatched)
57
+
58
+ close_top = lambda do
59
+ m = meta.pop
60
+ children = frames.pop
61
+ next unless m
62
+
63
+ value = @handlers[m[0]].call(m[1], m[2], children)
64
+ if frames.empty? || frames.last.nil?
65
+ results << value
66
+ else
67
+ frames.last << value
68
+ end
69
+ end
70
+
71
+ row = lambda do |name, attrs_pairs, text, depth|
72
+ close_top.call while frames.size > depth
73
+ handler = @handlers[name]
74
+ if handler
75
+ attrs = {}
76
+ i = 0
77
+ while i < attrs_pairs.length
78
+ attrs[attrs_pairs[i]] = attrs_pairs[i + 1]
79
+ i += 2
80
+ end
81
+ frames << []
82
+ meta << [name, attrs, text]
83
+ else
84
+ frames << nil
85
+ meta << nil
86
+ end
87
+ end
88
+
89
+ adapter = node.context.config.adapter
90
+ ran = adapter.plan_rows(node.native) do |name, pairs, text, depth|
91
+ row.call(name, pairs, text, depth)
92
+ end
93
+ unless ran
94
+ walk_wrappers(node) do |name, pairs, text, depth|
95
+ row.call(name, pairs, text, depth)
96
+ end
97
+ end
98
+
99
+ close_top.call while meta.any?
100
+ results
101
+ end
102
+
103
+ private
104
+
105
+ # Generic pre-order walk over the wrapper tree — the fallback
106
+ # stream for adapters without a bulk path. Same protocol as
107
+ # plan_rows: |name, attrs_pairs (flat [k, v, ...]), first-text,
108
+ # depth|.
109
+ def walk_wrappers(node, depth = 0)
110
+ case node
111
+ when Document
112
+ (root = node.root) && walk_wrappers(root, depth) { |*a| yield(*a) }
113
+ when Element
114
+ first_text = nil
115
+ node.children.each do |child|
116
+ first_text ||= child.content if child.is_a?(Text)
117
+ end
118
+ yield(node.name, attribute_pairs(node), first_text, depth)
119
+ node.children.each do |child|
120
+ walk_wrappers(child, depth + 1) { |*a| yield(*a) } if child.is_a?(Element)
121
+ end
122
+ end
123
+ end
124
+
125
+ def attribute_pairs(element)
126
+ pairs = []
127
+ element.attributes.each do |attr|
128
+ pairs << attr.name << attr.value
129
+ end
130
+ pairs
131
+ end
132
+ end
133
+ end
data/lib/moxml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.59"
4
+ VERSION = "0.5.60"
5
5
  end
data/lib/moxml.rb CHANGED
@@ -93,6 +93,7 @@ module Moxml
93
93
  autoload :XmlUtils, "moxml/xml_utils"
94
94
  autoload :XmlEmitter, "moxml/xml_emitter"
95
95
  autoload :Materializer, "moxml/materializer"
96
+ autoload :Plan, "moxml/plan"
96
97
  autoload :Adapter, "moxml/adapter"
97
98
  autoload :XPath, "moxml/xpath"
98
99
  autoload :SAX, "moxml/sax"
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Moxml::Plan: shape-compiled materialization over the adapter's
6
+ # bulk row stream (leptris C snapshot) and the generic wrapper walk.
7
+ # Both paths must produce identical values.
8
+ RSpec.describe Moxml::Plan do
9
+ PlanRow = Struct.new(:id, :kind, :fields) # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
10
+ PlanCell = Struct.new(:name, :unit, :value) # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
11
+
12
+ let(:xml) do
13
+ <<~XML
14
+ <?xml version="1.0"?>
15
+ <catalog>
16
+ <record id="r0" kind="k0"><field name="f0" unit="u0">v 0.0</field><field name="f1" unit="u1">v 0.1</field></record>
17
+ <record id="r1" kind="k1"><field name="f0" unit="u0">v 1.0</field></record>
18
+ </catalog>
19
+ XML
20
+ end
21
+ let(:plan) do
22
+ described_class.new do
23
+ on("record") do |attrs, _text, fields|
24
+ PlanRow.new(attrs["id"], attrs["kind"], fields)
25
+ end
26
+ on("field") do |attrs, text, _kids|
27
+ PlanCell.new(attrs["name"], attrs["unit"], text)
28
+ end
29
+ end
30
+ end
31
+
32
+ shared_examples "plan materialization" do |adapter_name|
33
+ let(:ctx) { Moxml.new(adapter_name) }
34
+
35
+ it "builds the typed shape" do
36
+ rows = plan.parse(xml, ctx)
37
+
38
+ expect(rows.size).to eq(2)
39
+ expect(rows[0].id).to eq("r0")
40
+ expect(rows[0].kind).to eq("k0")
41
+ expect(rows[0].fields.size).to eq(2)
42
+ expect(rows[0].fields[0]).to eq(PlanCell.new("f0", "u0", "v 0.0"))
43
+ expect(rows[0].fields[1]).to eq(PlanCell.new("f1", "u1", "v 0.1"))
44
+ expect(rows[1].fields[0].value).to eq("v 1.0")
45
+ end
46
+
47
+ it "drops values of unmatched parents" do
48
+ orphans = described_class.new do
49
+ on("field") { |attrs, text, _| PlanCell.new(attrs["name"], attrs["unit"], text) }
50
+ end.parse(xml, ctx)
51
+
52
+ # fields match everywhere; no record handler keeps them
53
+ expect(orphans.size).to eq(3)
54
+ end
55
+
56
+ it "returns top-level values for a matched root" do
57
+ roots = described_class.new do
58
+ on("catalog") { |_a, _t, kids| [:catalog, kids] }
59
+ on("record") { |attrs, _t, fields| [attrs["id"], fields] }
60
+ on("field") { |attrs, text, _| [attrs["name"], text] }
61
+ end.parse(xml, ctx)
62
+
63
+ expect(roots.size).to eq(1)
64
+ expect(roots[0][0]).to eq(:catalog)
65
+ expect(roots[0][1].size).to eq(2)
66
+ end
67
+
68
+ it "handles depth-3 nesting" do
69
+ deep = described_class.new do
70
+ on("catalog") { |_a, _t, records| records }
71
+ on("record") { |attrs, _t, fields| [attrs["id"], fields] }
72
+ on("field") { |attrs, text, _| [attrs["name"], text] }
73
+ end.parse(xml, ctx)
74
+
75
+ expect(deep.size).to eq(1)
76
+ expect(deep[0]).to eq(
77
+ [["r0", [["f0", "v 0.0"], ["f1", "v 0.1"]]],
78
+ ["r1", [["f0", "v 1.0"]]]],
79
+ )
80
+ end
81
+
82
+ it "is reusable across documents" do
83
+ first = plan.parse(xml, ctx)
84
+ second = plan.parse(xml, ctx)
85
+
86
+ expect(first).to eq(second)
87
+ end
88
+ end
89
+
90
+ describe "bulk path (leptris)" do
91
+ it_behaves_like "plan materialization", :leptris
92
+ end
93
+
94
+ describe "generic path" do
95
+ it_behaves_like "plan materialization", :nokogiri
96
+ end
97
+
98
+ it "requires a handler block" do
99
+ expect { described_class.new.on("x") }
100
+ .to raise_error(ArgumentError, /requires a block/)
101
+ end
102
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moxml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.59
4
+ version: 0.5.60
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-09-17 00:00:00.000000000 Z
11
+ date: 2026-09-18 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Moxml is a unified XML manipulation library that provides a common API
@@ -214,6 +214,7 @@ files:
214
214
  - lib/moxml/native_attachment/opal.rb
215
215
  - lib/moxml/node.rb
216
216
  - lib/moxml/node_set.rb
217
+ - lib/moxml/plan.rb
217
218
  - lib/moxml/processing_instruction.rb
218
219
  - lib/moxml/sax.rb
219
220
  - lib/moxml/sax/block_handler.rb
@@ -487,6 +488,7 @@ files:
487
488
  - spec/moxml/opal_oga_smoke_spec.rb
488
489
  - spec/moxml/opal_rexml_adapter_spec.rb
489
490
  - spec/moxml/opal_smoke_spec.rb
491
+ - spec/moxml/plan_spec.rb
490
492
  - spec/moxml/processing_instruction_spec.rb
491
493
  - spec/moxml/readonly_parse_spec.rb
492
494
  - spec/moxml/sax/namespace_splitter_spec.rb