moxml 0.5.151 → 0.5.152

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: 2d27f78bba118e07aa9790336f2d7cbc0545e48217fcaf7ec93b222a170cc398
4
- data.tar.gz: 6095220fc10e1f483d1ee3aed9b83c70ea1a61f0f304e00f4863a7fd52aca90c
3
+ metadata.gz: 5a384caeb57d40002410d3ebfb5ef9a22eed2793caab5db918d9bfeacaacbdee
4
+ data.tar.gz: 166f445cae5e401f0cf5be6a3f766eb9232809fe1217e3e90e46f2e315afce8e
5
5
  SHA512:
6
- metadata.gz: 7fec02ca86ee8bbe73cd220363316147d35f94c010399c3b8163bc616460f0b03382c249befa262304e5182532c1e2abca6852707345b64edfab1c8c1d1661d9
7
- data.tar.gz: 47f9e080e840c83a0b006d906bbf5e526d12324812c8d1a63c49cb12f2c3f0610078f98120a326080d6ba189bac7fe0131d82e628e58ad28878e1537bd1df96c
6
+ metadata.gz: cc19f58546f479a713b3039295b2eac40b5c83166dacc77af0017e83b40f2d3f8bffb028767f744ef29dae5688c3c5d3c34a248e9d3f75bede910b444c04b675
7
+ data.tar.gz: 9f9a64184feb0ba0a0eba61300a014339b81c7a58d69a1b1ff0dd4610abb7c239a6184e260253542f4643410fcfe3b15e54470b72cdb72283e7bf5a85f3d1639
@@ -595,6 +595,15 @@ namespace_validation_mode: :strict)
595
595
  false
596
596
  end
597
597
 
598
+ # Whether attach_child records the wrapper-side parent link
599
+ # (@parent_node) on the attached node. Adapters whose natives
600
+ # cannot resolve a parent through the adapter layer (raw-text
601
+ # natives, re-mints) need the link for remove/replace to find
602
+ # the tree — leave it on unless the adapter proves otherwise.
603
+ def attach_sets_parent_link?
604
+ true
605
+ end
606
+
598
607
  # Whether a BARE-name attribute READ addresses only the
599
608
  # no-namespace attribute (qualified-name semantics) — the
600
609
  # gate for Element#[]'s fast path (bare_attr_value).
@@ -71,6 +71,18 @@ module Moxml
71
71
  defined?(::Leptris::XML::NativeNode) &&
72
72
  Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.162.6")
73
73
 
74
+ # leptris-ruby#529: the whole XPath node-set result as an Array
75
+ # of registered natives in ONE C crossing (Document#
76
+ # xpath_result_rows_bulk). The eager lane below crosses once and
77
+ # mints one wrapper per row on iterate-every-result. Probed on
78
+ # the Document binding class (the class-level respond_to? for
79
+ # instance surfaces) AND on the compiled ext face — a fresh
80
+ # native_layer over a stale ext would NoMethodError mid-query.
81
+ BULK_XPATH_ROWS =
82
+ defined?(::Leptris::XML::Native) &&
83
+ ::Leptris::XML::Native.respond_to?(:xpath_result_rows_bulk) &&
84
+ ::Leptris::XML::Document.method_defined?(:xpath_result_rows_bulk)
85
+
74
86
  # leptris-ruby#460: set-text by document+address, kind-dispatched
75
87
  # in C — no binding twin mint per write. Face-probed, not
76
88
  # version-gated (the floor admits bindings without it).
@@ -78,6 +90,21 @@ module Moxml
78
90
  defined?(::Leptris::XML::Native) &&
79
91
  ::Leptris::XML::Native.respond_to?(:set_binding_text)
80
92
 
93
+ # leptris-ruby#532 (engine ride 1.9.379.x): Native.walk_columnar —
94
+ # a whole subtree's walk data ([names, handles, depths]; preorder,
95
+ # receiver at depth 0 — document/doctype receivers walk their
96
+ # children at depth 0) in ONE C crossing. The children face primes
97
+ # a per-document {native => children} map from that single call,
98
+ # so a consumer walk pays one crossing per cold subtree instead of
99
+ # one per node (leptris-ruby#527 measured the per-node
100
+ # children+name crossing at ~23% of cold-walk samples). Handles
101
+ # mint through the document's native-cache identity, so cached
102
+ # entries ARE the children_read wrappers. Face-probed, not
103
+ # version-gated.
104
+ COLUMNAR_WALK =
105
+ defined?(::Leptris::XML::Native) &&
106
+ ::Leptris::XML::Native.respond_to?(:walk_columnar)
107
+
81
108
  # 1.9.163.2's native layer returns UTF-8, unfrozen strings
82
109
  # (1.9.162.x answered ASCII-8BIT — the reads retagged with a
83
110
  # dup+force_encoding per call).
@@ -1203,6 +1230,9 @@ module Moxml
1203
1230
  # itself out, no moxml release needed.
1204
1231
  XPATH_INDEX_STALE = :xpath_index_stale
1205
1232
 
1233
+ # Attachment key for the columnar children map (COLUMNAR_WALK).
1234
+ COLUMNAR_CHILDREN = :columnar_children_map
1235
+
1206
1236
  def mark_xpath_index_stale(node)
1207
1237
  return if native_xpath_index_fixed?
1208
1238
 
@@ -1253,6 +1283,8 @@ module Moxml
1253
1283
  end
1254
1284
 
1255
1285
  def set_root(doc, element)
1286
+ drop_columnar_children(doc)
1287
+ drop_columnar_children(element)
1256
1288
  to_binding(doc).root = to_binding(element)
1257
1289
  end
1258
1290
 
@@ -1331,6 +1363,19 @@ module Moxml
1331
1363
  true
1332
1364
  end
1333
1365
 
1366
+ # Identity-stable natives resolve their parent through the
1367
+ # adapter (remove/replace fall back to that resolution when
1368
+ # the wrapper-side link is unset), and every mutation bumps
1369
+ # the children generation — so recording the link on attach
1370
+ # only materializes a fourth ivar per attached node
1371
+ # (object-shape transition, ~6% of the build row). Adapters
1372
+ # whose natives cannot self-resolve keep the link via
1373
+ # Base#attach_sets_parent_link?; parse_fragment mints its own
1374
+ # lifetime chain (#245) either way.
1375
+ def attach_sets_parent_link?
1376
+ false
1377
+ end
1378
+
1334
1379
  # Our at_xpath falls back to the model's Ruby engine when the
1335
1380
  # native lane answers nothing — the engine's node-set XPath
1336
1381
  # index is stale after sibling insertion, so `at` must not be
@@ -1639,6 +1684,7 @@ module Moxml
1639
1684
  # re-indented). Raw-pointer walk over the root subtree — the
1640
1685
  # same batch child-pointer pattern the materializer uses.
1641
1686
  def strip_blank_text_nodes!(doc)
1687
+ drop_columnar_children(doc)
1642
1688
  binding_ffi = ::Leptris::XML::FFI
1643
1689
  root = binding_ffi.leptris_document_root(doc.c_ptr)
1644
1690
  return unless root && !root.null?
@@ -1788,7 +1834,11 @@ module Moxml
1788
1834
 
1789
1835
  def create_primed_element(name, owner_doc:, context:, adapter:)
1790
1836
  validate_element_name(name)
1791
- owner_doc.native_create_element(name.to_s, context, adapter)
1837
+ # One dispatch: the compiled mint IS the wrapper factory.
1838
+ # The binding's native_create_element hop added a Ruby frame
1839
+ # (plus a redundant to_s and nil-branch) around a C call
1840
+ # that takes the same four arguments.
1841
+ ::Leptris::XML::NativeNode.create_element(owner_doc, name.to_s, context, adapter)
1792
1842
  end
1793
1843
 
1794
1844
  def create_native_text(content, owner_doc = nil)
@@ -2132,6 +2182,8 @@ module Moxml
2132
2182
  # source. Answer the installed binding element; callers re-point
2133
2183
  # wrappers at it.
2134
2184
  def adopt_root(doc, element)
2185
+ drop_columnar_children(doc)
2186
+ drop_columnar_children(element)
2135
2187
  return nil unless NATIVE_ROOT_ADOPT
2136
2188
 
2137
2189
  binding_doc = to_binding(doc)
@@ -2170,6 +2222,18 @@ module Moxml
2170
2222
  # TextSegment reconstruction reads binding text nodes;
2171
2223
  # the wrapper's memo supplies the flag, so deriving it
2172
2224
  # here costs no C parent climb per call.
2225
+ # Plain children-of-node read (no entity-marker split): the
2226
+ # columnar map answers from the last cold-subtree crossing —
2227
+ # one walk_columnar primes every child list under the asked
2228
+ # node, so a top-down consumer walk pays one crossing at the
2229
+ # root and none below it (COLUMNAR_WALK above).
2230
+ if COLUMNAR_WALK && !entity_bearing
2231
+ hit = columnar_children_hit(node)
2232
+ return hit if hit
2233
+
2234
+ return prime_columnar_children(node)
2235
+ end
2236
+
2173
2237
  natives = if NATIVE_BULK_FIXED
2174
2238
  NN_CHILDREN.bind_call(node).to_a
2175
2239
  else
@@ -2224,6 +2288,80 @@ module Moxml
2224
2288
  end
2225
2289
  end
2226
2290
 
2291
+ # Map hit for a primed node (see COLUMNAR_WALK). Served dup'd so
2292
+ # no consumer can mutate the cached list in place.
2293
+ def columnar_children_hit(node)
2294
+ doc = doc_for(node)
2295
+ return nil unless doc
2296
+
2297
+ map = attachments.get(doc, COLUMNAR_CHILDREN)
2298
+ return nil unless map&.key?(node)
2299
+
2300
+ map[node].dup
2301
+ end
2302
+
2303
+ # One walk_columnar crossing over node's subtree; stores a
2304
+ # children list for every visited node and returns node's own
2305
+ # list. Preorder + depths rebuild the parent chain on a stack;
2306
+ # the receiver's child depth is read off the first row (the
2307
+ # binding walks document/doctype receivers' children at depth
2308
+ # 0). Docless detached subtrees answer the receiver's list
2309
+ # unprimed.
2310
+ def prime_columnar_children(node)
2311
+ _names, handles, depths =
2312
+ ::Leptris::XML::Native.walk_columnar(node)
2313
+ return [] if handles.empty?
2314
+
2315
+ child_depth =
2316
+ if handles[0].equal?(node)
2317
+ depths[0] + 1
2318
+ else
2319
+ depths[0]
2320
+ end
2321
+ doc = doc_for(node)
2322
+ map = doc ? {} : nil
2323
+ mine = []
2324
+ stack = []
2325
+ handles.each_with_index do |handle, i|
2326
+ depth = depths[i]
2327
+ stack[depth] = handle
2328
+ map[handle] = [] if map
2329
+ if depth >= child_depth && map
2330
+ parent = stack[depth - 1]
2331
+ map[parent] << handle if map.key?(parent)
2332
+ end
2333
+ mine << handle if depth == child_depth
2334
+ end
2335
+ attachments.set(doc, COLUMNAR_CHILDREN, map) if map
2336
+ mine
2337
+ end
2338
+
2339
+ # Structural seams drop the columnar map wholesale; the next
2340
+ # children read re-primes it (see COLUMNAR_WALK). Cheap no-op
2341
+ # when no map is live: one lock-free attachment probe.
2342
+ def drop_columnar_children(node)
2343
+ return unless COLUMNAR_WALK
2344
+
2345
+ doc =
2346
+ case node
2347
+ when ::Leptris::XML::NativeNode
2348
+ doc_for(node)
2349
+ when ::Leptris::XML::Document
2350
+ node
2351
+ else
2352
+ begin
2353
+ node.document
2354
+ rescue StandardError
2355
+ nil
2356
+ end
2357
+ end
2358
+ return if doc.nil?
2359
+
2360
+ return if attachments.get(doc, COLUMNAR_CHILDREN).nil?
2361
+
2362
+ attachments.delete(doc, COLUMNAR_CHILDREN)
2363
+ end
2364
+
2227
2365
  def native_keep_entity_refs?(node)
2228
2366
  return false unless NATIVE_READ_LAYER &&
2229
2367
  node.is_a?(::Leptris::XML::NativeNode)
@@ -2437,6 +2575,7 @@ module Moxml
2437
2575
  # callers fall back to create_element + set_attribute +
2438
2576
  # add_child when absent.
2439
2577
  def create_element_with_attrs(parent, name, attrs)
2578
+ drop_columnar_children(parent)
2440
2579
  validate_element_name(name)
2441
2580
  binding_parent = to_binding(parent)
2442
2581
  doc = binding_parent.document or return nil
@@ -2517,7 +2656,22 @@ module Moxml
2517
2656
  attr.name.to_s.dup.force_encoding("UTF-8")
2518
2657
  end
2519
2658
 
2659
+ # Self-gating by method_defined? — older exts fall through to
2660
+ # the to_binding bridge below.
2661
+ SET_ATTRIBUTE_NATIVE =
2662
+ NATIVE_READ_LAYER && NN.method_defined?(:set_attribute)
2663
+
2520
2664
  def set_attribute(element, name, value)
2665
+ # Native write face (leptris-ruby#204's nn_set_attribute):
2666
+ # gates + version bump + engine set in one C frame on the
2667
+ # TypedData self — the builder hot path skips the to_binding
2668
+ # bridge (FFI::Pointer + wrapper mint + registry) entirely.
2669
+ # Cache coherence stays at the resolver: callers invalidate
2670
+ # after this returns, exactly as for the bridge.
2671
+ if SET_ATTRIBUTE_NATIVE && element.is_a?(::Leptris::XML::NativeNode)
2672
+ return element.set_attribute(name.to_s, value.to_s)
2673
+ end
2674
+
2521
2675
  element = to_binding(element) if NATIVE_READ_LAYER
2522
2676
  element[name.to_s] = value.to_s
2523
2677
  end
@@ -2728,6 +2882,8 @@ module Moxml
2728
2882
  # on the source, so a living source can never outlive the
2729
2883
  # memory its reads still touch.
2730
2884
  def absorb_source_document(dest, owner)
2885
+ drop_columnar_children(dest)
2886
+ drop_columnar_children(owner)
2731
2887
  return nil unless NATIVE_DOC_ABSORB
2732
2888
  return :direct if attachments.get(dest, :absorbed_sources)&.include?(owner)
2733
2889
 
@@ -2773,6 +2929,8 @@ module Moxml
2773
2929
  end
2774
2930
 
2775
2931
  def add_child(parent, child)
2932
+ drop_columnar_children(parent)
2933
+ drop_columnar_children(child)
2776
2934
  result = add_child_impl(parent, child)
2777
2935
  # A successful child insertion rebuilds the engine's
2778
2936
  # document index; the upstream sibling-insert bug leaves it
@@ -2894,6 +3052,8 @@ module Moxml
2894
3052
  end
2895
3053
 
2896
3054
  def add_previous_sibling(node, new_node)
3055
+ drop_columnar_children(node)
3056
+ drop_columnar_children(new_node)
2897
3057
  result = add_previous_sibling_impl(node, new_node)
2898
3058
  mark_xpath_index_stale(node)
2899
3059
  result
@@ -2940,6 +3100,8 @@ module Moxml
2940
3100
  end
2941
3101
 
2942
3102
  def add_next_sibling(node, new_node)
3103
+ drop_columnar_children(node)
3104
+ drop_columnar_children(new_node)
2943
3105
  result = add_next_sibling_impl(node, new_node)
2944
3106
  mark_xpath_index_stale(node)
2945
3107
  result
@@ -2993,6 +3155,7 @@ module Moxml
2993
3155
  end
2994
3156
 
2995
3157
  def remove(node)
3158
+ drop_columnar_children(node)
2996
3159
  node = to_binding(node) if NATIVE_READ_LAYER
2997
3160
  case node
2998
3161
  when CustomizedLeptris::Declaration
@@ -3024,6 +3187,8 @@ module Moxml
3024
3187
  end
3025
3188
 
3026
3189
  def replace(node, new_node)
3190
+ drop_columnar_children(node)
3191
+ drop_columnar_children(new_node)
3027
3192
  if NATIVE_READ_LAYER
3028
3193
  node = to_binding(node)
3029
3194
  new_node = to_binding(new_node)
@@ -3080,6 +3245,7 @@ module Moxml
3080
3245
  Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.290.0")
3081
3246
 
3082
3247
  def replace_children(node, new_children)
3248
+ drop_columnar_children(node)
3083
3249
  # Cross-pool children: the binding's bulk face adopts per
3084
3250
  # node and fails on mixed kinds (inner_xml= with
3085
3251
  # text+elements died with Memory allocation failed —
@@ -3169,6 +3335,7 @@ module Moxml
3169
3335
  ::Leptris::XML::NativeNode.method_defined?(:set_text_native)
3170
3336
 
3171
3337
  def set_text_content(node, content)
3338
+ drop_columnar_children(node)
3172
3339
  if SET_TEXT_NATIVE && node.is_a?(::Leptris::XML::NativeNode)
3173
3340
  begin
3174
3341
  node.set_text_native(content.to_s)
@@ -3466,6 +3633,22 @@ module Moxml
3466
3633
  )
3467
3634
  end
3468
3635
 
3636
+ # leptris-ruby#529 bulk face: the whole nodeset as an Array
3637
+ # of registered natives in ONE crossing — the eager lane
3638
+ # below crosses once per row (the scoreboard's iterate-
3639
+ # every-result row). Real rows mint through the registered
3640
+ # klasses with native-cache identity; attr/sequence rows
3641
+ # arrive as the ResultAttr/ResultText wrappers the eager
3642
+ # mint produces, so NodeSet's per-row wrap sees one shape
3643
+ # and the result contract is unchanged. The face CONSUMES
3644
+ # the pointer for nodesets; a non-nodeset result answers
3645
+ # nil and leaves the pointer — fall through to the scalar
3646
+ # wrap, whose free owns it.
3647
+ if BULK_XPATH_ROWS
3648
+ rows = document.xpath_result_rows_bulk(result_ptr)
3649
+ return rows unless rows.nil?
3650
+ end
3651
+
3469
3652
  # The binding's #to_a mints one Ruby wrapper per result
3470
3653
  # node; hand the native set through so .size/.first stay
3471
3654
  # native-side (LazyNodeSet holds it unmaterialized).
@@ -163,22 +163,26 @@ module Moxml
163
163
  end
164
164
 
165
165
  def create_element(name)
166
- # The preprimed mint (leptris binding with the C-primed
167
- # create_element): the mint already wrote @context/@adapter in
168
- # C, so the returned node IS the finished wrapper and the Ruby
169
- # prime pass disappears from the build path.
170
- native = if adapter.mints_primed?
171
- adapter.create_primed_element(name, owner_doc: @native,
172
- context: context,
173
- adapter: adapter)
166
+ # The mint path dereferenced `adapter` three times and re-probed
167
+ # `mints_primed?` twice per element; the probe reads a class
168
+ # constant, so its verdict is fixed for the context's lifetime —
169
+ # deref once into a local and memoize the probe (the accessor's
170
+ # context hop ran on every mint otherwise).
171
+ a = @adapter || (@adapter = context.config.adapter)
172
+ primed = @mints_primed
173
+ primed = (@mints_primed = a.mints_primed?) if primed.nil?
174
+ native = if primed
175
+ a.create_primed_element(name, owner_doc: @native,
176
+ context: context, adapter: a)
174
177
  else
175
- adapter.create_element(name, owner_doc: @native)
178
+ a.create_element(name, owner_doc: @native)
176
179
  end
177
180
  # leptris-ruby#508 mints registered klasses at create: the node
178
181
  # IS a contract-carrying identity (@native is self) — prime it
179
182
  # in place instead of shelling a second wrapper (a raw mint
180
183
  # reaching ==/adapter dies on the nil @context).
181
- return native.prime_contract(native, context, adapter) if native.is_a?(Moxml::Node)
184
+ return native if primed && native.is_a?(Moxml::Node)
185
+ return native.prime_contract(native, context, a) if native.is_a?(Moxml::Node)
182
186
 
183
187
  Wrappers::Element.new(native, context)
184
188
  end
data/lib/moxml/node.rb CHANGED
@@ -215,7 +215,20 @@ module Moxml
215
215
 
216
216
  def attach_child(node)
217
217
  context.bump_children_generation
218
- node = prepare_node(node)
218
+ # Inlined prepare_node: the per-attach dispatch frame measured
219
+ # ~4.4% on the builder hot path (deletion experiment). Same
220
+ # contract — String operands mint literal text nodes, foreign
221
+ # types raise.
222
+ node = case node
223
+ when Node then node
224
+ when String then Moxml::Node.wrap(adapter.create_text(node), context)
225
+ else
226
+ raise Moxml::DocumentStructureError.new(
227
+ "Invalid node type: #{node.class}. Expected String or Moxml::Node",
228
+ operation: "attach_child",
229
+ state: "node_type: #{node.class}",
230
+ )
231
+ end
219
232
  oc = node.context
220
233
  if !oc.nil? && !oc.equal?(context) &&
221
234
  oc.namespace_scopes_active?
@@ -229,7 +242,15 @@ module Moxml
229
242
  refreshed = adapter.actual_native(node.native, @native)
230
243
  node.refresh_native!(refreshed) if refreshed && refreshed != node.native
231
244
  end
232
- node.parent_node = self
245
+ # The wrapper-side parent link rides the adapter's capability
246
+ # gate: adapters whose natives cannot resolve a parent through
247
+ # the adapter layer need it for remove/replace to find the tree
248
+ # (ox's raw-text natives, for one); identity-stable adapters
249
+ # skip the write, which otherwise materializes a fourth ivar on
250
+ # every attached node (object-shape transition per attach).
251
+ # Wrappers that need the link anyway get it where they are
252
+ # minted with one — NodeSet#each re-points them.
253
+ node.parent_node = self if a.attach_sets_parent_link?
233
254
  # The adopted subtree's in-scope namespaces changed
234
255
  node.invalidate_namespace_cache!
235
256
  invalidate_children_cache!
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.151"
4
+ VERSION = "0.5.152"
5
5
  end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "leptris"
5
+ rescue LoadError
6
+ # Leptris gem not available - skip all specs in this file
7
+ return
8
+ end
9
+ return unless Leptris::XML::Document.respond_to?(:create)
10
+ # The columnar children lane rides the binding's walk_columnar face
11
+ # (leptris-ruby#527/#532); older bindings keep the per-node children
12
+ # crossing, so the routing cannot be pinned there.
13
+ return unless defined?(Leptris::XML::Native) &&
14
+ Leptris::XML::Native.respond_to?(:walk_columnar)
15
+
16
+ require "moxml/adapter/leptris"
17
+
18
+ RSpec.describe "Moxml::Adapter::Leptris columnar children (SOTA B2 walk row)" do
19
+ around do |example|
20
+ Moxml.with_config(:leptris, true, "UTF-8") do
21
+ example.run
22
+ end
23
+ end
24
+
25
+ let(:xml) do
26
+ <<~XML
27
+ <?xml version="1.0"?><r><a x="1">t</a><b/><c><d/></c><tail>z</tail></r>
28
+ XML
29
+ end
30
+
31
+ let(:doc) { Moxml.parse(xml) }
32
+ let(:root) { doc.root }
33
+ let(:root_native) { root.native }
34
+
35
+ # Counts real Native.walk_columnar C crossings inside the block —
36
+ # no stubs, the actual boundary traffic the walk row pays.
37
+ def with_crossing_count
38
+ calls = 0
39
+ tp = TracePoint.new(:c_call) do |t|
40
+ calls += 1 if t.method_id == :walk_columnar
41
+ end
42
+ result = nil
43
+ tp.enable { result = yield }
44
+ [calls, result]
45
+ end
46
+
47
+ describe "the columnar gate" do
48
+ it "face-probes walk_columnar on the binding" do
49
+ expect(Moxml::Adapter::Leptris::COLUMNAR_WALK).to be(true)
50
+ expect(Leptris::XML::Native.respond_to?(:walk_columnar)).to be(true)
51
+ end
52
+ end
53
+
54
+ describe "plain children reads" do
55
+ it "answer natives identical to the native children face" do
56
+ got = Moxml::Adapter::Leptris.children(root_native)
57
+ expected = Leptris::XML::NativeNode
58
+ .instance_method(:children).bind_call(root_native).to_a
59
+ expect(got.size).to eq(expected.size)
60
+ expect(got.map { |n| Moxml::Adapter::Leptris.node_name(n) })
61
+ .to eq(expected.map(&:name))
62
+ got.each_with_index do |native, i|
63
+ # native-cache identity: cached handles ARE the children_read
64
+ # wrappers, so the NodeSet contract holds.
65
+ expect(native).to equal(expected[i])
66
+ end
67
+ end
68
+
69
+ # Parsed-document wrappers mint with Identity::ChildReads above
70
+ # Moxml::Node, so consumer iteration on those wrappers dispatches
71
+ # to the binding's C read layer. The children FACE is pinned by
72
+ # driving it for every node of the cold subtree: one walk_columnar
73
+ # crossing primes the whole map, every deeper face read hits it.
74
+ it "pay ONE columnar crossing for a whole cold subtree" do
75
+ calls, = with_crossing_count do
76
+ walk_face = lambda do |native|
77
+ Moxml::Adapter::Leptris.children(native).each do |child|
78
+ walk_face.call(child)
79
+ end
80
+ end
81
+ walk_face.call(root_native)
82
+ end
83
+ expect(calls).to eq(1)
84
+ end
85
+
86
+ it "serve repeat face reads from the primed map without a crossing" do
87
+ calls, first = with_crossing_count do
88
+ Moxml::Adapter::Leptris.children(root_native)
89
+ end
90
+ expect(calls).to eq(1)
91
+ first_names = first.map { |n| Moxml::Adapter::Leptris.node_name(n) }
92
+ calls2, second = with_crossing_count do
93
+ Moxml::Adapter::Leptris.children(root_native)
94
+ end
95
+ expect(calls2).to eq(0)
96
+ expect(second.map { |n| Moxml::Adapter::Leptris.node_name(n) })
97
+ .to eq(first_names)
98
+ end
99
+ end
100
+
101
+ describe "mutation invalidation" do
102
+ # Read back through the adapter face: the map must never serve a
103
+ # list a mutation has invalidated.
104
+ def face_children_names
105
+ Moxml::Adapter::Leptris.children(root_native)
106
+ .map { |n| Moxml::Adapter::Leptris.node_name(n) }
107
+ end
108
+
109
+ it "sees children added after the map was primed" do
110
+ face_children_names # prime the map
111
+ root.add_child(doc.create_element("late"))
112
+ expect(face_children_names).to include("late")
113
+ end
114
+
115
+ it "sees children removed after the map was primed" do
116
+ face_children_names # prime the map
117
+ root.children.to_a.first.remove
118
+ expect(face_children_names).not_to include("a")
119
+ end
120
+
121
+ it "sees sibling insertions after the map was primed" do
122
+ face_children_names # prime the map
123
+ anchor = root.children.to_a.first
124
+ anchor.add_previous_sibling(doc.create_element("before2"))
125
+ expect(face_children_names).to include("before2")
126
+ end
127
+
128
+ it "sees replace after the map was primed" do
129
+ face_children_names # prime the map
130
+ victim = root.children.to_a.find { |n| n.name == "b" }
131
+ victim.replace(doc.create_element("swapped"))
132
+ expect(face_children_names).to include("swapped")
133
+ expect(face_children_names).not_to include("b")
134
+ end
135
+
136
+ it "sees content rewrites after the map was primed" do
137
+ face_children_names # prime the map
138
+ target = root.children.to_a.find { |n| n.name == "a" }
139
+ target.content = "rewritten"
140
+ names = face_children_names
141
+ expect(names).to include("a")
142
+ expect(root.children.to_a.find { |n| n.name == "a" }.text)
143
+ .to eq("rewritten")
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "leptris"
5
+ rescue LoadError
6
+ # Leptris gem not available - skip all specs in this file
7
+ return
8
+ end
9
+
10
+ require "moxml/adapter/leptris"
11
+
12
+ # leptris-ruby#529: the multi-result native XPath lane routes through
13
+ # Document#xpath_result_rows_bulk when the binding answers it — the
14
+ # whole node-set in ONE C crossing instead of one crossing per row.
15
+ # The rows keep the eager lane's contract (registered wrapper
16
+ # klasses, ResultAttr/ResultText synthetic rows, scalar pass-through
17
+ # on non-node-set results), so every consumer-visible shape is
18
+ # unchanged; what pins here is the routing gate and those shapes.
19
+ RSpec.describe "leptris xpath bulk rows routing" do
20
+ let(:ctx) { Moxml.new(:leptris) }
21
+ let(:doc) do
22
+ ctx.parse(<<~XML)
23
+ <root>
24
+ <item id="1">alpha</item>
25
+ <item id="2">beta</item>
26
+ <!-- note -->
27
+ </root>
28
+ XML
29
+ end
30
+
31
+ it "arms the gate on the current binding" do
32
+ expect(Leptris::XML::Native.respond_to?(:xpath_result_rows_bulk)).to be(true)
33
+ expect(Leptris::XML::Document.method_defined?(:xpath_result_rows_bulk)).to be(true)
34
+ expect(Moxml::Adapter::Leptris::BULK_XPATH_ROWS).to be(true)
35
+ end
36
+
37
+ it "answers multi-result queries with wrapped natives in order" do
38
+ set = doc.xpath("//item")
39
+ expect(set).to be_a(Moxml::NodeSet)
40
+ expect(set.size).to eq(2)
41
+ expect(set.map(&:name)).to eq(%w[item item])
42
+ expect(set.map(&:text)).to eq(%w[alpha beta])
43
+ expect(set.first).to be_a(Moxml::Element)
44
+ end
45
+
46
+ it "keeps attribute rows as Attribute wrappers" do
47
+ attrs = doc.xpath("//item/@id")
48
+ expect(attrs.map(&:name)).to eq(%w[id id])
49
+ expect(attrs.map(&:value)).to eq(%w[1 2])
50
+ expect(attrs.first).to be_a(Moxml::Attribute)
51
+ end
52
+
53
+ it "keeps mixed element and comment rows in document order" do
54
+ set = doc.xpath("/root/node()").grep_v(Moxml::Text)
55
+ expect(set.map { |n| n.is_a?(Moxml::Comment) ? :comment : n.name.to_sym })
56
+ .to eq(%i[item item comment])
57
+ end
58
+
59
+ it "passes scalars through unwrapped" do
60
+ expect(doc.xpath("count(//item)")).to eq(2.0)
61
+ expect(doc.xpath("string(//item)")).to eq("alpha")
62
+ expect(doc.xpath("//item[@id='9']")).to be_a(Moxml::NodeSet)
63
+ expect(doc.xpath("//item[@id='9']").empty?).to be(true)
64
+ end
65
+
66
+ it "resolves one address to one Ruby object across queries" do
67
+ first = doc.xpath("//item").first
68
+ again = doc.xpath("//item").first
69
+ expect(again).to be(first)
70
+ end
71
+ 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.151
4
+ version: 0.5.152
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-21 00:00:00.000000000 Z
11
+ date: 2026-10-22 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
@@ -481,6 +481,7 @@ files:
481
481
  - spec/moxml/adapter/leptris_append_gc_spec.rb
482
482
  - spec/moxml/adapter/leptris_attribute_snapshot_spec.rb
483
483
  - spec/moxml/adapter/leptris_binding_cap_spec.rb
484
+ - spec/moxml/adapter/leptris_children_columnar_spec.rb
484
485
  - spec/moxml/adapter/leptris_contract_reads_spec.rb
485
486
  - spec/moxml/adapter/leptris_detached_text_spec.rb
486
487
  - spec/moxml/adapter/leptris_doc_parts_spec.rb
@@ -493,6 +494,7 @@ files:
493
494
  - spec/moxml/adapter/leptris_sax_records_spec.rb
494
495
  - spec/moxml/adapter/leptris_spec.rb
495
496
  - spec/moxml/adapter/leptris_strict_mismatch_spec.rb
497
+ - spec/moxml/adapter/leptris_xpath_bulk_rows_spec.rb
496
498
  - spec/moxml/adapter/leptris_xpath_identity_spec.rb
497
499
  - spec/moxml/adapter/libxml_internals_spec.rb
498
500
  - spec/moxml/adapter/libxml_spec.rb