lutaml-model 0.8.61 → 0.8.63
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 +4 -4
- data/.github/workflows/release.yml +12 -1
- data/lib/lutaml/model/schema/generator/definition.rb +50 -30
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/adapter/xml_parser.rb +37 -0
- data/spec/lutaml/model/schema/generation_regressions_spec.rb +43 -8
- data/spec/lutaml/model/schema/json_schema_spec.rb +1 -20
- data/spec/lutaml/model/schema/yaml_schema_spec.rb +0 -17
- data/spec/lutaml/xml/adapter/recovered_fatal_parse_spec.rb +99 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e60ce4a1bedec13a3b778b0afea4c9be5c4779f721916bfafeb151e3bd8f8c1e
|
|
4
|
+
data.tar.gz: b6f519fabe6c9dc9c0c5edeb871d6afcc23d6439eff433b15b74b532b1a633d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 646c4cfdfb4dc395f5f339332eb214b0d4cba7bf31710884357e5d8519db8825e0a8e937824f268bac8642ea5d23a7fee75686988b9d3a54923f508e50aaa3c0
|
|
7
|
+
data.tar.gz: d738e9c618790d6575691acc7349991f1b4f28986947e75a3dc40a883978cbc3ce7f6ef28021b7c85f822037c6c63137ed22f1930fe6aba36dd0b266f84a451b
|
|
@@ -44,6 +44,12 @@ jobs:
|
|
|
44
44
|
runs-on: ubuntu-latest
|
|
45
45
|
steps:
|
|
46
46
|
- uses: actions/checkout@v6
|
|
47
|
+
# The release job commits the version bump to main while this job
|
|
48
|
+
# runs; a checkout of the dispatch-time HEAD reads the previous
|
|
49
|
+
# version and smoke-tests the wrong gem (0.8.60 was smoke-checked
|
|
50
|
+
# for the 0.8.61 release). Pull post-bump main before resolving.
|
|
51
|
+
with:
|
|
52
|
+
fetch-depth: 5
|
|
47
53
|
- uses: ruby/setup-ruby@v1
|
|
48
54
|
with:
|
|
49
55
|
ruby-version: "3.4"
|
|
@@ -52,8 +58,13 @@ jobs:
|
|
|
52
58
|
# From the version file, not git describe: the default checkout
|
|
53
59
|
# is shallow with no tags, so git describe failed and the empty
|
|
54
60
|
# version produced `gem install "lutaml-model:"`.
|
|
55
|
-
run:
|
|
61
|
+
run: |
|
|
62
|
+
git pull --ff-only origin main
|
|
63
|
+
echo "version=$(ruby -e 'print File.read(%q{lib/lutaml/model/version.rb})[/VERSION\s*=\s*"([^"]+)"/, 1]')" >> "$GITHUB_OUTPUT"
|
|
56
64
|
- name: Install and smoke the released gem
|
|
57
65
|
run: |
|
|
58
66
|
gem install "lutaml-model:${{ steps.version.outputs.version }}" --no-document
|
|
67
|
+
# The round-trip script exercises all five formats; the TOML
|
|
68
|
+
# legs require an adapter gem to be present.
|
|
69
|
+
gem install tomlib toml-rb --no-document
|
|
59
70
|
ruby .github/scripts/test_installed_gem.rb
|
|
@@ -23,19 +23,22 @@ module Lutaml
|
|
|
23
23
|
},
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
# Choice validation
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
26
|
+
# Choice validation follows the serializer's contract: unset
|
|
27
|
+
# or empty members are omitted from output, and instances that
|
|
28
|
+
# leave a whole group empty are accepted (#869). Members are
|
|
29
|
+
# therefore never `required`; a max:1 group contributes an
|
|
30
|
+
# at-most-one constraint over its members, and independent
|
|
31
|
+
# groups combine conjunctively under allOf. Groups whose range
|
|
32
|
+
# admits several members impose no serialization constraint.
|
|
32
33
|
if type.choice_attributes.any?
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
groups = type.choice_attributes
|
|
35
|
+
.select { |choice| choice.max == 1 }
|
|
36
|
+
.map { |choice| { "oneOf" => exclusive_branches(choice) } }
|
|
37
|
+
|
|
38
|
+
if groups.one?
|
|
39
|
+
@schema[name].merge!(groups.first)
|
|
40
|
+
elsif groups.any?
|
|
41
|
+
@schema[name]["allOf"] = groups
|
|
39
42
|
end
|
|
40
43
|
end
|
|
41
44
|
|
|
@@ -44,30 +47,47 @@ module Lutaml
|
|
|
44
47
|
|
|
45
48
|
private
|
|
46
49
|
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
key = choice.min == 1 && choice.max == 1 ? "oneOf" : "anyOf"
|
|
52
|
-
{ key => choice_branches(choice) }
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def choice_branches(choice)
|
|
56
|
-
choice.attributes.map do |member|
|
|
50
|
+
# Leaves of the group (nested choices collapse into it), as
|
|
51
|
+
# [name, attribute] pairs.
|
|
52
|
+
def choice_leaves(choice)
|
|
53
|
+
choice.attributes.flat_map do |member|
|
|
57
54
|
if member.is_a?(Lutaml::Model::Choice)
|
|
58
|
-
|
|
55
|
+
choice_leaves(member)
|
|
59
56
|
else
|
|
60
|
-
|
|
61
|
-
"type" => "object",
|
|
62
|
-
"properties" => PropertiesCollection.from_attributes(
|
|
63
|
-
[member], extract_register_from(type)
|
|
64
|
-
).to_schema,
|
|
65
|
-
"required" => [member.name.to_s],
|
|
66
|
-
}
|
|
57
|
+
[[member.name.to_s, member]]
|
|
67
58
|
end
|
|
68
59
|
end
|
|
69
60
|
end
|
|
70
61
|
|
|
62
|
+
# Exactly-one-present construction over the leaves: no leaf, or
|
|
63
|
+
# exactly one leaf present without the others. Accepts the
|
|
64
|
+
# all-empty document the serializer emits; rejects documents
|
|
65
|
+
# with two members of the group at once (max:1).
|
|
66
|
+
def exclusive_branches(choice)
|
|
67
|
+
leaves = choice_leaves(choice)
|
|
68
|
+
names = leaves.map(&:first)
|
|
69
|
+
none = {
|
|
70
|
+
"not" => {
|
|
71
|
+
"anyOf" => names.map { |n| { "required" => [n] } },
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
singles = leaves.map do |name, attribute|
|
|
75
|
+
others = names - [name]
|
|
76
|
+
{
|
|
77
|
+
"allOf" => (
|
|
78
|
+
[{
|
|
79
|
+
"type" => "object",
|
|
80
|
+
"properties" => PropertiesCollection.from_attributes(
|
|
81
|
+
[attribute], extract_register_from(type)
|
|
82
|
+
).to_schema,
|
|
83
|
+
"required" => [name],
|
|
84
|
+
}] + others.map { |o| { "not" => { "required" => [o] } } }
|
|
85
|
+
),
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
[none] + singles
|
|
89
|
+
end
|
|
90
|
+
|
|
71
91
|
def properties_to_schema(type)
|
|
72
92
|
PropertiesCollection.from_class(type).to_schema
|
|
73
93
|
end
|
data/lib/lutaml/model/version.rb
CHANGED
|
@@ -20,6 +20,7 @@ module Lutaml
|
|
|
20
20
|
raw_xml = xml
|
|
21
21
|
xml = normalize_xml_for_parse(xml)
|
|
22
22
|
parsed = parse_with_moxml(xml, parse_encoding)
|
|
23
|
+
assert_no_truncated_recovered_parse!(parsed)
|
|
23
24
|
root_element = parsed.root
|
|
24
25
|
|
|
25
26
|
raise_empty_document_error if root_element.nil?
|
|
@@ -30,6 +31,25 @@ module Lutaml
|
|
|
30
31
|
new(root, parse_encoding, **parse_document_options(raw_xml))
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
# Recover-mode parsing must never masquerade input truncation as
|
|
35
|
+
# success.
|
|
36
|
+
#
|
|
37
|
+
# libxml2 (the Nokogiri backend) caps its input buffer at 10 MB;
|
|
38
|
+
# longer documents trip a fatal "Resource limit exceeded: Buffer
|
|
39
|
+
# size limit exceeded, try XML_PARSE_HUGE" error mid-input. In
|
|
40
|
+
# recover mode Nokogiri records that error on the document and
|
|
41
|
+
# returns the partial tree, so every node past the limit silently
|
|
42
|
+
# vanishes (lutaml-model#871). A resource-limit fatal means the
|
|
43
|
+
# engine stopped early with input left — refuse to deserialize the
|
|
44
|
+
# partial document instead of dropping trailing content without a
|
|
45
|
+
# trace.
|
|
46
|
+
#
|
|
47
|
+
# Other recover-mode fatals (e.g. an XML declaration after leading
|
|
48
|
+
# whitespace) do not drop content and keep the long-standing
|
|
49
|
+
# recover behavior; only the resource-limit family is truncating.
|
|
50
|
+
TRUNCATION_FATAL_MARKER =
|
|
51
|
+
/Resource limit exceeded|Buffer size limit exceeded/
|
|
52
|
+
|
|
33
53
|
private
|
|
34
54
|
|
|
35
55
|
def normalize_xml_for_parse(xml)
|
|
@@ -62,6 +82,23 @@ module Lutaml
|
|
|
62
82
|
end
|
|
63
83
|
end
|
|
64
84
|
|
|
85
|
+
def assert_no_truncated_recovered_parse!(parsed)
|
|
86
|
+
errors = parsed.parse_errors
|
|
87
|
+
return if errors.nil? || errors.empty?
|
|
88
|
+
|
|
89
|
+
fatal = errors.find do |message|
|
|
90
|
+
message.match?(TRUNCATION_FATAL_MARKER)
|
|
91
|
+
end
|
|
92
|
+
return if fatal.nil?
|
|
93
|
+
|
|
94
|
+
raise Lutaml::Model::InvalidFormatError.new(
|
|
95
|
+
:xml,
|
|
96
|
+
"the XML engine reported a fatal resource limit and recovered " \
|
|
97
|
+
"with a truncated document; refusing to deserialize " \
|
|
98
|
+
"partial input (#{fatal})",
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
65
102
|
def parse_document_options(xml)
|
|
66
103
|
{
|
|
67
104
|
doctype: extract_doctype_from_xml(xml),
|
|
@@ -70,25 +70,60 @@ RSpec.describe "JSON/YAML schema generation regressions (#863, #864, #865, #866)
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
describe "#864: nested choice blocks" do
|
|
73
|
-
it "
|
|
73
|
+
it "collapses the nested group into the outer at-most-one set" do
|
|
74
74
|
schema = JSON.parse(Lutaml::Model::Schema.to_json(SchemaRepro::Outer))
|
|
75
75
|
one_of = schema["$defs"]["SchemaRepro_Outer"]["oneOf"]
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
expect(at_branch).to be_truthy
|
|
79
|
-
expect(inner["oneOf"].map { |b| b["required"] })
|
|
80
|
-
.to contain_exactly(%w[from], %w[to])
|
|
76
|
+
requireds = one_of.map { |b| b["allOf"].to_a.filter_map { |s| s["required"]&.first } }
|
|
77
|
+
expect(requireds).to contain_exactly([], %w[from], %w[to], %w[at])
|
|
81
78
|
end
|
|
82
79
|
end
|
|
83
80
|
|
|
84
81
|
describe "#865: independent choices combine conjunctively" do
|
|
85
|
-
it "renders an allOf of oneOf groups
|
|
82
|
+
it "renders an allOf of oneOf at-most-one groups" do
|
|
86
83
|
schema = JSON.parse(Lutaml::Model::Schema.to_json(SchemaRepro::Relation))
|
|
87
84
|
all_of = schema["$defs"]["SchemaRepro_Relation"]["allOf"]
|
|
88
85
|
expect(all_of.length).to eq(2)
|
|
89
86
|
all_of.each do |group|
|
|
90
87
|
expect(group).to have_key("oneOf")
|
|
91
|
-
group["oneOf"].
|
|
88
|
+
expect(group["oneOf"].length).to eq(3)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
describe "#869: the serializer's own output validates" do
|
|
94
|
+
it "emits no required keys for initialize_empty choice members" do
|
|
95
|
+
schema = JSON.parse(Lutaml::Model::Schema.to_json(SchemaRepro::Relation))
|
|
96
|
+
definition = schema["$defs"]["SchemaRepro_Relation"]
|
|
97
|
+
groups = definition["allOf"] || [definition]
|
|
98
|
+
groups.each do |group|
|
|
99
|
+
group["oneOf"].each do |branch|
|
|
100
|
+
Array(branch["allOf"]).each do |sub|
|
|
101
|
+
expect(sub).not_to have_key("required") if sub.key?("not")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "accepts the document the default instance serializes to" do
|
|
108
|
+
inst = SchemaRepro::Relation.new(type: "includes")
|
|
109
|
+
doc = inst.to_hash
|
|
110
|
+
expect(doc).to eq("type" => "includes")
|
|
111
|
+
schema = JSON.parse(Lutaml::Model::Schema.to_json(SchemaRepro::Relation))
|
|
112
|
+
definition = schema["$defs"]["SchemaRepro_Relation"]
|
|
113
|
+
groups = definition["allOf"] || [definition]
|
|
114
|
+
groups.each do |group|
|
|
115
|
+
satisfied = group["oneOf"].count do |branch|
|
|
116
|
+
Array(branch["allOf"]).all? do |sub|
|
|
117
|
+
if sub.key?("not")
|
|
118
|
+
Array(sub["not"]["anyOf"]).none? do |req|
|
|
119
|
+
doc.key?(req["required"].first)
|
|
120
|
+
end
|
|
121
|
+
else
|
|
122
|
+
doc.key?(sub["required"].first)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
expect(satisfied).to eq(1)
|
|
92
127
|
end
|
|
93
128
|
end
|
|
94
129
|
end
|
|
@@ -161,26 +161,7 @@ RSpec.describe Lutaml::Model::Schema::JsonSchema do
|
|
|
161
161
|
"type" => ["string", "null"],
|
|
162
162
|
},
|
|
163
163
|
},
|
|
164
|
-
|
|
165
|
-
{
|
|
166
|
-
"type" => "object",
|
|
167
|
-
"properties" => {
|
|
168
|
-
"email" => {
|
|
169
|
-
"type" => ["string", "null"],
|
|
170
|
-
},
|
|
171
|
-
},
|
|
172
|
-
"required" => ["email"],
|
|
173
|
-
},
|
|
174
|
-
{
|
|
175
|
-
"type" => "object",
|
|
176
|
-
"properties" => {
|
|
177
|
-
"phone" => {
|
|
178
|
-
"type" => ["string", "null"],
|
|
179
|
-
},
|
|
180
|
-
},
|
|
181
|
-
"required" => ["phone"],
|
|
182
|
-
},
|
|
183
|
-
],
|
|
164
|
+
|
|
184
165
|
},
|
|
185
166
|
},
|
|
186
167
|
}
|
|
@@ -136,23 +136,6 @@ RSpec.describe Lutaml::Model::Schema::YamlSchema do
|
|
|
136
136
|
type:
|
|
137
137
|
- string
|
|
138
138
|
- 'null'
|
|
139
|
-
anyOf:
|
|
140
|
-
- type: object
|
|
141
|
-
properties:
|
|
142
|
-
email:
|
|
143
|
-
type:
|
|
144
|
-
- string
|
|
145
|
-
- 'null'
|
|
146
|
-
required:
|
|
147
|
-
- email
|
|
148
|
-
- type: object
|
|
149
|
-
properties:
|
|
150
|
-
phone:
|
|
151
|
-
type:
|
|
152
|
-
- string
|
|
153
|
-
- 'null'
|
|
154
|
-
required:
|
|
155
|
-
- phone
|
|
156
139
|
YAML
|
|
157
140
|
end
|
|
158
141
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
require "lutaml/model"
|
|
6
|
+
require "lutaml/xml"
|
|
7
|
+
require "lutaml/xml/adapter/nokogiri_adapter"
|
|
8
|
+
|
|
9
|
+
# Regression tests for lutaml-model#871.
|
|
10
|
+
#
|
|
11
|
+
# libxml2 (the Nokogiri backend) caps its input buffer at 10 MB. Parsing a
|
|
12
|
+
# longer document in recover mode records a FATAL "Resource limit exceeded:
|
|
13
|
+
# Buffer size limit exceeded, try XML_PARSE_HUGE" error on the document and
|
|
14
|
+
# returns the partial tree, so every node past the cap silently vanishes.
|
|
15
|
+
# XmlParser now refuses documents whose parse_errors carry a fatal error
|
|
16
|
+
# instead of deserializing the truncated input.
|
|
17
|
+
RSpec.describe "recovered fatal parse errors" do
|
|
18
|
+
let(:annex_class) do
|
|
19
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
20
|
+
attribute :id, :string
|
|
21
|
+
|
|
22
|
+
xml do
|
|
23
|
+
element "annex"
|
|
24
|
+
map_attribute "id", to: :id
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
let(:root_class) do
|
|
30
|
+
annex = annex_class
|
|
31
|
+
|
|
32
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
33
|
+
attribute :annexes, annex, collection: true
|
|
34
|
+
|
|
35
|
+
xml do
|
|
36
|
+
element "metanorma"
|
|
37
|
+
map_element "annex", to: :annexes
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build_document(chunk_count:, chunk_size:, with_tail: false)
|
|
43
|
+
filler = "A" * chunk_size
|
|
44
|
+
document = +"<metanorma>"
|
|
45
|
+
chunk_count.times do |index|
|
|
46
|
+
document << %(<annex id="annex#{index}" data="#{filler}"/>)
|
|
47
|
+
end
|
|
48
|
+
document << %(<annex id="annex-tail-marker"/>) if with_tail
|
|
49
|
+
document << "</metanorma>"
|
|
50
|
+
document
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Six 1.7 MB attribute values push the cumulative input past libxml2's
|
|
54
|
+
# 10 MB buffer cap mid-document, mirroring the issue's failing manifest.
|
|
55
|
+
def over_cap_document
|
|
56
|
+
build_document(chunk_count: 6, chunk_size: 1_700_000, with_tail: true)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "hits the libxml2 buffer cap in recover mode past 10 MB" do
|
|
60
|
+
errors = Nokogiri::XML(over_cap_document) { |config| config.recover.nonet }.errors
|
|
61
|
+
|
|
62
|
+
expect(errors.map(&:message).join("\n")).to match(/\bFATAL:/)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "raises instead of silently truncating on the nokogiri path" do
|
|
66
|
+
Lutaml::Model::Config.with_adapter(xml: :nokogiri) do
|
|
67
|
+
expect { root_class.from_xml(over_cap_document) }
|
|
68
|
+
.to raise_error(Lutaml::Model::InvalidFormatError, /resource limit.*truncated/m)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "raises from the adapter layer directly" do
|
|
73
|
+
Lutaml::Model::Config.with_adapter(xml: :nokogiri) do
|
|
74
|
+
expect { Lutaml::Xml::Adapter::NokogiriAdapter.parse(over_cap_document) }
|
|
75
|
+
.to raise_error(Lutaml::Model::InvalidFormatError, /XML_PARSE_HUGE/)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "keeps parsing documents whose fatals do not drop content" do
|
|
80
|
+
xml = " <?xml version=\"1.0\"?>\n<metanorma><annex id=\"a\"/></metanorma>"
|
|
81
|
+
|
|
82
|
+
Lutaml::Model::Config.with_adapter(xml: :nokogiri) do
|
|
83
|
+
model = root_class.from_xml(xml)
|
|
84
|
+
|
|
85
|
+
expect(model.annexes.map(&:id)).to eq(["a"])
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "still parses smaller documents without any fatal-error handling" do
|
|
90
|
+
Lutaml::Model::Config.with_adapter(xml: :nokogiri) do
|
|
91
|
+
model = root_class.from_xml(
|
|
92
|
+
build_document(chunk_count: 2, chunk_size: 500_000, with_tail: true),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
expect(model.annexes.map(&:id))
|
|
96
|
+
.to eq(["annex0", "annex1", "annex-tail-marker"])
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lutaml-model
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.63
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -1902,6 +1902,7 @@ files:
|
|
|
1902
1902
|
- spec/lutaml/xml/adapter/oga_adapter_spec.rb
|
|
1903
1903
|
- spec/lutaml/xml/adapter/order_spec.rb
|
|
1904
1904
|
- spec/lutaml/xml/adapter/ox_adapter_spec.rb
|
|
1905
|
+
- spec/lutaml/xml/adapter/recovered_fatal_parse_spec.rb
|
|
1905
1906
|
- spec/lutaml/xml/adapter/rexml_adapter_spec.rb
|
|
1906
1907
|
- spec/lutaml/xml/adapter/xml_namespace_spec.rb
|
|
1907
1908
|
- spec/lutaml/xml/adapter_loader_spec.rb
|