ROXML 3.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +6 -0
- data/.gitmodules +3 -0
- data/History.txt +299 -0
- data/MIT-LICENSE +18 -0
- data/README.rdoc +161 -0
- data/Rakefile +95 -0
- data/TODO +39 -0
- data/VERSION +1 -0
- data/config/website.yml +2 -0
- data/examples/amazon.rb +35 -0
- data/examples/current_weather.rb +27 -0
- data/examples/dashed_elements.rb +20 -0
- data/examples/library.rb +40 -0
- data/examples/posts.rb +27 -0
- data/examples/rails.rb +70 -0
- data/examples/twitter.rb +37 -0
- data/examples/xml/active_record.xml +70 -0
- data/examples/xml/amazon.xml +133 -0
- data/examples/xml/current_weather.xml +89 -0
- data/examples/xml/dashed_elements.xml +52 -0
- data/examples/xml/posts.xml +23 -0
- data/examples/xml/twitter.xml +422 -0
- data/lib/roxml.rb +547 -0
- data/lib/roxml/definition.rb +236 -0
- data/lib/roxml/hash_definition.rb +25 -0
- data/lib/roxml/xml.rb +43 -0
- data/lib/roxml/xml/parsers/libxml.rb +91 -0
- data/lib/roxml/xml/parsers/nokogiri.rb +77 -0
- data/lib/roxml/xml/references.rb +297 -0
- data/roxml.gemspec +201 -0
- data/spec/definition_spec.rb +486 -0
- data/spec/examples/active_record_spec.rb +40 -0
- data/spec/examples/amazon_spec.rb +54 -0
- data/spec/examples/current_weather_spec.rb +37 -0
- data/spec/examples/dashed_elements_spec.rb +20 -0
- data/spec/examples/library_spec.rb +46 -0
- data/spec/examples/post_spec.rb +24 -0
- data/spec/examples/twitter_spec.rb +32 -0
- data/spec/roxml_spec.rb +372 -0
- data/spec/shared_specs.rb +15 -0
- data/spec/spec.opts +1 -0
- data/spec/spec_helper.rb +14 -0
- data/spec/support/libxml.rb +3 -0
- data/spec/support/nokogiri.rb +3 -0
- data/spec/xml/attributes_spec.rb +36 -0
- data/spec/xml/namespace_spec.rb +240 -0
- data/spec/xml/namespaces_spec.rb +32 -0
- data/spec/xml/parser_spec.rb +26 -0
- data/tasks/rdoc.rake +13 -0
- data/tasks/rspec.rake +25 -0
- data/tasks/test.rake +35 -0
- data/test/fixtures/book_malformed.xml +5 -0
- data/test/fixtures/book_pair.xml +8 -0
- data/test/fixtures/book_text_with_attribute.xml +5 -0
- data/test/fixtures/book_valid.xml +5 -0
- data/test/fixtures/book_with_authors.xml +7 -0
- data/test/fixtures/book_with_contributions.xml +9 -0
- data/test/fixtures/book_with_contributors.xml +7 -0
- data/test/fixtures/book_with_contributors_attrs.xml +7 -0
- data/test/fixtures/book_with_default_namespace.xml +9 -0
- data/test/fixtures/book_with_depth.xml +6 -0
- data/test/fixtures/book_with_octal_pages.xml +4 -0
- data/test/fixtures/book_with_publisher.xml +7 -0
- data/test/fixtures/book_with_wrapped_attr.xml +3 -0
- data/test/fixtures/dictionary_of_attr_name_clashes.xml +8 -0
- data/test/fixtures/dictionary_of_attrs.xml +6 -0
- data/test/fixtures/dictionary_of_guarded_names.xml +6 -0
- data/test/fixtures/dictionary_of_mixeds.xml +4 -0
- data/test/fixtures/dictionary_of_name_clashes.xml +10 -0
- data/test/fixtures/dictionary_of_names.xml +4 -0
- data/test/fixtures/dictionary_of_texts.xml +10 -0
- data/test/fixtures/library.xml +30 -0
- data/test/fixtures/library_uppercase.xml +30 -0
- data/test/fixtures/muffins.xml +3 -0
- data/test/fixtures/nameless_ageless_youth.xml +2 -0
- data/test/fixtures/node_with_attr_name_conflicts.xml +1 -0
- data/test/fixtures/node_with_name_conflicts.xml +4 -0
- data/test/fixtures/numerology.xml +4 -0
- data/test/fixtures/person.xml +1 -0
- data/test/fixtures/person_with_guarded_mothers.xml +13 -0
- data/test/fixtures/person_with_mothers.xml +10 -0
- data/test/mocks/dictionaries.rb +57 -0
- data/test/mocks/mocks.rb +279 -0
- data/test/support/fixtures.rb +11 -0
- data/test/test_helper.rb +34 -0
- data/test/unit/definition_test.rb +235 -0
- data/test/unit/deprecations_test.rb +24 -0
- data/test/unit/to_xml_test.rb +81 -0
- data/test/unit/xml_attribute_test.rb +39 -0
- data/test/unit/xml_block_test.rb +81 -0
- data/test/unit/xml_bool_test.rb +122 -0
- data/test/unit/xml_convention_test.rb +150 -0
- data/test/unit/xml_hash_test.rb +115 -0
- data/test/unit/xml_initialize_test.rb +49 -0
- data/test/unit/xml_name_test.rb +141 -0
- data/test/unit/xml_namespace_test.rb +31 -0
- data/test/unit/xml_object_test.rb +207 -0
- data/test/unit/xml_required_test.rb +94 -0
- data/test/unit/xml_text_test.rb +71 -0
- data/website/index.html +98 -0
- metadata +254 -0
@@ -0,0 +1,236 @@
|
|
1
|
+
require 'lib/roxml/hash_definition'
|
2
|
+
|
3
|
+
class Module
|
4
|
+
def bool_attr_reader(*attrs)
|
5
|
+
attrs.each do |attr|
|
6
|
+
define_method :"#{attr}?" do
|
7
|
+
instance_variable_get(:"@#{attr}") || false
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
module ROXML
|
14
|
+
class ContradictoryNamespaces < StandardError
|
15
|
+
end
|
16
|
+
|
17
|
+
class Definition # :nodoc:
|
18
|
+
attr_reader :name, :type, :wrapper, :hash, :blocks, :accessor, :to_xml, :attr_name, :namespace
|
19
|
+
bool_attr_reader :name_explicit, :array, :cdata, :required, :frozen
|
20
|
+
|
21
|
+
def initialize(sym, opts = {}, &block)
|
22
|
+
opts.assert_valid_keys(:from, :in, :as, :namespace,
|
23
|
+
:else, :required, :frozen, :cdata, :to_xml)
|
24
|
+
@default = opts.delete(:else)
|
25
|
+
@to_xml = opts.delete(:to_xml)
|
26
|
+
@name_explicit = opts.has_key?(:from) && opts[:from].is_a?(String)
|
27
|
+
@cdata = opts.delete(:cdata)
|
28
|
+
@required = opts.delete(:required)
|
29
|
+
@frozen = opts.delete(:frozen)
|
30
|
+
@wrapper = opts.delete(:in)
|
31
|
+
@namespace = opts.delete(:namespace)
|
32
|
+
|
33
|
+
@accessor = sym.to_s
|
34
|
+
opts[:as] ||=
|
35
|
+
if @accessor.ends_with?('?')
|
36
|
+
:bool
|
37
|
+
elsif @accessor.ends_with?('_on')
|
38
|
+
Date
|
39
|
+
elsif @accessor.ends_with?('_at')
|
40
|
+
DateTime
|
41
|
+
end
|
42
|
+
|
43
|
+
@array = opts[:as].is_a?(Array)
|
44
|
+
@blocks = collect_blocks(block, opts[:as])
|
45
|
+
|
46
|
+
@type = extract_type(opts[:as])
|
47
|
+
if @type.respond_to?(:roxml_tag_name)
|
48
|
+
opts[:from] ||= @type.roxml_tag_name
|
49
|
+
end
|
50
|
+
|
51
|
+
if opts[:from] == :content
|
52
|
+
opts[:from] = '.'
|
53
|
+
elsif opts[:from] == :name
|
54
|
+
opts[:from] = '*'
|
55
|
+
elsif opts[:from] == :attr
|
56
|
+
@type = :attr
|
57
|
+
opts[:from] = nil
|
58
|
+
elsif opts[:from] == :name
|
59
|
+
opts[:from] = '*'
|
60
|
+
elsif opts[:from].to_s.starts_with?('@')
|
61
|
+
@type = :attr
|
62
|
+
opts[:from].sub!('@', '')
|
63
|
+
end
|
64
|
+
|
65
|
+
@attr_name = accessor.to_s.chomp('?')
|
66
|
+
@name = (opts[:from] || @attr_name).to_s
|
67
|
+
@name = @name.singularize if hash? || array?
|
68
|
+
if hash? && (hash.key.name? || hash.value.name?)
|
69
|
+
@name = '*'
|
70
|
+
end
|
71
|
+
raise ContradictoryNamespaces if @name.include?(':') && (@namespace.present? || @namespace == false)
|
72
|
+
|
73
|
+
raise ArgumentError, "Can't specify both :else default and :required" if required? && @default
|
74
|
+
end
|
75
|
+
|
76
|
+
def instance_variable_name
|
77
|
+
:"@#{attr_name}"
|
78
|
+
end
|
79
|
+
|
80
|
+
def setter
|
81
|
+
:"#{attr_name}="
|
82
|
+
end
|
83
|
+
|
84
|
+
def hash
|
85
|
+
if hash?
|
86
|
+
@type.wrapper ||= name
|
87
|
+
@type
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def hash?
|
92
|
+
@type.is_a?(HashDefinition)
|
93
|
+
end
|
94
|
+
|
95
|
+
def name?
|
96
|
+
@name == '*'
|
97
|
+
end
|
98
|
+
|
99
|
+
def content?
|
100
|
+
@name == '.'
|
101
|
+
end
|
102
|
+
|
103
|
+
def default
|
104
|
+
if @default.nil?
|
105
|
+
@default = [] if array?
|
106
|
+
@default = {} if hash?
|
107
|
+
end
|
108
|
+
@default.duplicable? ? @default.dup : @default
|
109
|
+
end
|
110
|
+
|
111
|
+
def to_ref(inst)
|
112
|
+
case type
|
113
|
+
when :attr then XMLAttributeRef
|
114
|
+
when :text then XMLTextRef
|
115
|
+
when HashDefinition then XMLHashRef
|
116
|
+
when Symbol then raise ArgumentError, "Invalid type argument #{type}"
|
117
|
+
else XMLObjectRef
|
118
|
+
end.new(self, inst)
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
122
|
+
def self.all(items, &block)
|
123
|
+
array = items.is_a?(Array)
|
124
|
+
results = (array ? items : [items]).map do |item|
|
125
|
+
yield item
|
126
|
+
end
|
127
|
+
|
128
|
+
array ? results : results.first
|
129
|
+
end
|
130
|
+
|
131
|
+
def self.fetch_bool(value, default)
|
132
|
+
value = value.to_s.downcase
|
133
|
+
if %w{true yes 1 t}.include? value
|
134
|
+
true
|
135
|
+
elsif %w{false no 0 f}.include? value
|
136
|
+
false
|
137
|
+
else
|
138
|
+
default
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
CORE_BLOCK_SHORTHANDS = {
|
143
|
+
# Core Shorthands
|
144
|
+
Integer => lambda do |val|
|
145
|
+
all(val) do |v|
|
146
|
+
Integer(v) unless v.blank?
|
147
|
+
end
|
148
|
+
end,
|
149
|
+
Float => lambda do |val|
|
150
|
+
all(val) do |v|
|
151
|
+
Float(v) unless v.blank?
|
152
|
+
end
|
153
|
+
end,
|
154
|
+
Fixnum => lambda do |val|
|
155
|
+
all(val) do |v|
|
156
|
+
v.to_i unless v.blank?
|
157
|
+
end
|
158
|
+
end,
|
159
|
+
Time => lambda do |val|
|
160
|
+
all(val) {|v| Time.parse(v) unless v.blank? }
|
161
|
+
end,
|
162
|
+
|
163
|
+
:bool => nil,
|
164
|
+
:bool_standalone => lambda do |val|
|
165
|
+
all(val) do |v|
|
166
|
+
fetch_bool(v, nil)
|
167
|
+
end
|
168
|
+
end,
|
169
|
+
:bool_combined => lambda do |val|
|
170
|
+
all(val) do |v|
|
171
|
+
fetch_bool(v, v)
|
172
|
+
end
|
173
|
+
end
|
174
|
+
}
|
175
|
+
|
176
|
+
def self.block_shorthands
|
177
|
+
# dynamically load these shorthands at class definition time, but
|
178
|
+
# only if they're already availbable
|
179
|
+
CORE_BLOCK_SHORTHANDS.tap do |blocks|
|
180
|
+
blocks.reverse_merge!(BigDecimal => lambda do |val|
|
181
|
+
all(val) do |v|
|
182
|
+
BigDecimal.new(v) unless v.blank?
|
183
|
+
end
|
184
|
+
end) if defined?(BigDecimal)
|
185
|
+
|
186
|
+
blocks.reverse_merge!(DateTime => lambda do |val|
|
187
|
+
if defined?(DateTime)
|
188
|
+
all(val) {|v| DateTime.parse(v) unless v.blank? }
|
189
|
+
end
|
190
|
+
end) if defined?(DateTime)
|
191
|
+
|
192
|
+
blocks.reverse_merge!(Date => lambda do |val|
|
193
|
+
if defined?(Date)
|
194
|
+
all(val) {|v| Date.parse(v) unless v.blank? }
|
195
|
+
end
|
196
|
+
end) if defined?(Date)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def collect_blocks(block, as)
|
201
|
+
if as.is_a?(Array)
|
202
|
+
if as.size > 1
|
203
|
+
raise ArgumentError, "multiple :as types (#{as.map(&:inspect).join(', ')}) is not supported. Use a block if you want more complicated behavior."
|
204
|
+
end
|
205
|
+
|
206
|
+
as = as.first
|
207
|
+
end
|
208
|
+
|
209
|
+
if as == :bool
|
210
|
+
# if a second block is present, and we can't coerce the xml value
|
211
|
+
# to bool, we need to be able to pass it to the user-provided block
|
212
|
+
as = (block ? :bool_combined : :bool_standalone)
|
213
|
+
end
|
214
|
+
as = self.class.block_shorthands.fetch(as) do
|
215
|
+
unless as.respond_to?(:from_xml) || (as.respond_to?(:first) && as.first.respond_to?(:from_xml)) || (as.is_a?(Hash) && !(as.keys & [:key, :value]).empty?)
|
216
|
+
raise ArgumentError, "Invalid :as argument #{as}" unless as.nil?
|
217
|
+
end
|
218
|
+
nil
|
219
|
+
end
|
220
|
+
[as, block].compact
|
221
|
+
end
|
222
|
+
|
223
|
+
def extract_type(as)
|
224
|
+
if as.is_a?(Hash)
|
225
|
+
return HashDefinition.new(as)
|
226
|
+
elsif as.respond_to?(:from_xml)
|
227
|
+
return as
|
228
|
+
elsif as.is_a?(Array) && as.first.respond_to?(:from_xml)
|
229
|
+
@array = true
|
230
|
+
return as.first
|
231
|
+
else
|
232
|
+
:text
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
236
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module ROXML
|
2
|
+
class HashDefinition # :nodoc:
|
3
|
+
attr_reader :key, :value
|
4
|
+
attr_accessor :wrapper
|
5
|
+
|
6
|
+
def initialize(opts)
|
7
|
+
opts.assert_valid_keys(:key, :value)
|
8
|
+
|
9
|
+
@key = Definition.new(nil, to_definition_options(opts, :key))
|
10
|
+
@value = Definition.new(nil, to_definition_options(opts, :value))
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
def to_definition_options(opts, what)
|
15
|
+
case opts[what]
|
16
|
+
when Hash
|
17
|
+
opts[what]
|
18
|
+
when String, Symbol
|
19
|
+
{:from => opts[what]}
|
20
|
+
else
|
21
|
+
raise ArgumentError, "unrecognized hash parameter: #{what} => #{opts[what]}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/lib/roxml/xml.rb
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
module ROXML
|
2
|
+
unless const_defined? 'XML_PARSER'
|
3
|
+
PREFERRED_PARSERS = %w[nokogiri libxml].freeze
|
4
|
+
parsers = PREFERRED_PARSERS.dup
|
5
|
+
begin
|
6
|
+
require parsers.first
|
7
|
+
XML_PARSER = parsers.first # :nodoc:
|
8
|
+
rescue LoadError
|
9
|
+
if parsers.size > 1
|
10
|
+
parsers.shift
|
11
|
+
retry
|
12
|
+
else
|
13
|
+
raise "Could not load either nokogiri or libxml"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
require File.join('lib/roxml/xml/parsers', XML_PARSER)
|
19
|
+
|
20
|
+
module XML
|
21
|
+
class Node
|
22
|
+
def self.from(data)
|
23
|
+
case data
|
24
|
+
when XML::Node
|
25
|
+
data
|
26
|
+
when XML::Document
|
27
|
+
data.root
|
28
|
+
when File, IO
|
29
|
+
Parser.parse_io(data).root
|
30
|
+
else
|
31
|
+
if (defined?(URI) && data.is_a?(URI::Generic)) ||
|
32
|
+
(defined?(Pathname) && data.is_a?(Pathname))
|
33
|
+
Parser.parse_file(data.to_s).root
|
34
|
+
else
|
35
|
+
Parser.parse(data).root
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
require 'lib/roxml/xml/references'
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'libxml'
|
2
|
+
require 'cgi'
|
3
|
+
|
4
|
+
module ROXML
|
5
|
+
module XML # :nodoc:all
|
6
|
+
Document = LibXML::XML::Document
|
7
|
+
Node = LibXML::XML::Node
|
8
|
+
Parser = LibXML::XML::Parser
|
9
|
+
Error = LibXML::XML::Error
|
10
|
+
|
11
|
+
module NamespacedSearch
|
12
|
+
def search(xpath, roxml_namespaces = {})
|
13
|
+
if namespaces.default
|
14
|
+
roxml_namespaces = {:xmlns => namespaces.default.href}.merge(roxml_namespaces)
|
15
|
+
end
|
16
|
+
if roxml_namespaces.present?
|
17
|
+
find(xpath, roxml_namespaces.map {|prefix, href| [prefix, href].join(':') })
|
18
|
+
else
|
19
|
+
find(xpath)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
def namespaced(xpath)
|
25
|
+
xpath.split('/').map do |component|
|
26
|
+
if component =~ /\w+/ && !component.include?(':') && !component.starts_with?('@')
|
27
|
+
"xmlns:#{component}"
|
28
|
+
else
|
29
|
+
component
|
30
|
+
end
|
31
|
+
end.join('/')
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class Document
|
36
|
+
include NamespacedSearch
|
37
|
+
|
38
|
+
def default_namespace
|
39
|
+
default = namespaces.default
|
40
|
+
default.prefix || 'xmlns' if default
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
delegate :namespaces, :to => :root
|
45
|
+
end
|
46
|
+
|
47
|
+
class Node
|
48
|
+
include NamespacedSearch
|
49
|
+
|
50
|
+
class << self
|
51
|
+
def new_with_entity_escaping(name, content = nil, namespace = nil)
|
52
|
+
new_without_entity_escaping(name, content && CGI.escapeHTML(content), namespace)
|
53
|
+
end
|
54
|
+
alias_method_chain :new, :entity_escaping
|
55
|
+
|
56
|
+
alias :create :new
|
57
|
+
end
|
58
|
+
|
59
|
+
def default_namespace
|
60
|
+
doc.default_namespace
|
61
|
+
end
|
62
|
+
|
63
|
+
def add_child(child)
|
64
|
+
# libxml 1.1.3 changed child_add from returning child to returning self
|
65
|
+
self << child
|
66
|
+
child
|
67
|
+
end
|
68
|
+
|
69
|
+
alias_method :set_libxml_content, :content=
|
70
|
+
def content=(string)
|
71
|
+
set_libxml_content(string.gsub('&', '&'))
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
class Parser
|
76
|
+
class << self
|
77
|
+
def parse(str_data)
|
78
|
+
string(str_data).parse
|
79
|
+
end
|
80
|
+
|
81
|
+
def parse_file(path)
|
82
|
+
file(path).parse
|
83
|
+
end
|
84
|
+
|
85
|
+
def parse_io(stream)
|
86
|
+
io(stream).parse
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'nokogiri'
|
2
|
+
|
3
|
+
module ROXML
|
4
|
+
module XML # :nodoc:all
|
5
|
+
Document = Nokogiri::XML::Document
|
6
|
+
Element = Nokogiri::XML::Element
|
7
|
+
Node = Nokogiri::XML::Node
|
8
|
+
|
9
|
+
module Error; end
|
10
|
+
|
11
|
+
class Parser
|
12
|
+
class << self
|
13
|
+
def parse(string)
|
14
|
+
Nokogiri::XML(string)
|
15
|
+
end
|
16
|
+
|
17
|
+
def parse_file(path) #:nodoc:
|
18
|
+
path = path.sub('file:', '') if path.starts_with?('file:')
|
19
|
+
parse(open(path))
|
20
|
+
end
|
21
|
+
|
22
|
+
def parse_io(stream) #:nodoc:
|
23
|
+
parse(stream)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
class Document
|
29
|
+
def save(path)
|
30
|
+
open(path, 'w') do |file|
|
31
|
+
file << serialize
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def default_namespace
|
36
|
+
'xmlns' if root.namespaces['xmlns']
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
module NodeExtensions
|
41
|
+
def search(xpath, roxml_namespaces = {})
|
42
|
+
xpath = "./#{xpath}"
|
43
|
+
(roxml_namespaces.present? ? super(xpath, roxml_namespaces) : super(xpath)).map {|i| i }
|
44
|
+
end
|
45
|
+
|
46
|
+
def attributes
|
47
|
+
self
|
48
|
+
end
|
49
|
+
|
50
|
+
def default_namespace
|
51
|
+
document.default_namespace
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
class Element
|
56
|
+
include NodeExtensions
|
57
|
+
|
58
|
+
def empty?
|
59
|
+
children.empty?
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
class Node
|
64
|
+
class << self
|
65
|
+
def new_cdata(content)
|
66
|
+
Nokogiri::XML::CDATA.new(Document.new, content)
|
67
|
+
end
|
68
|
+
|
69
|
+
def create(name)
|
70
|
+
new(name, Document.new)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
include NodeExtensions
|
74
|
+
alias :remove! :remove
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|