ruby_ami 1.3.4 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +1 -0
- data/CHANGELOG.md +5 -2
- data/README.md +14 -15
- data/features/support/lexer_helper.rb +18 -0
- data/lib/ruby_ami/action.rb +19 -65
- data/lib/ruby_ami/agi_result_parser.rb +2 -2
- data/lib/ruby_ami/client.rb +28 -159
- data/lib/ruby_ami/error.rb +2 -2
- data/lib/ruby_ami/event.rb +2 -2
- data/lib/ruby_ami/response.rb +6 -8
- data/lib/ruby_ami/stream.rb +76 -11
- data/lib/ruby_ami/version.rb +1 -1
- data/lib/ruby_ami.rb +0 -9
- data/ruby_ami.gemspec +0 -4
- data/spec/ruby_ami/action_spec.rb +27 -78
- data/spec/ruby_ami/agi_result_parser_spec.rb +0 -9
- data/spec/ruby_ami/client_spec.rb +17 -287
- data/spec/ruby_ami/event_spec.rb +24 -30
- data/spec/ruby_ami/response_spec.rb +12 -20
- data/spec/ruby_ami/stream_spec.rb +142 -49
- data/spec/spec_helper.rb +2 -3
- data/spec/support/mock_server.rb +0 -4
- metadata +2 -59
- data/lib/ruby_ami/metaprogramming.rb +0 -18
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cb7606f130bf3753537649990fbc188a62b31441
|
4
|
+
data.tar.gz: eef9574ee77d8721fb9b480978595ec412a82857
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 29b7868e1c9e4a99babf6a169b97741b53ea05dfa5b8a0d31de0caa6e919912b8aab09863ec3c874c6e2fabec9e674789e9524212117a64c198b4b9799eba34e
|
7
|
+
data.tar.gz: 4213b880af9d91452b351b15ee5d55261e00954bacd79632f04467343e52371a1ee725d301216046d01ed83005b7f23e90eb8adce24ef63c94c9dce289be075e
|
data/.travis.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -1,7 +1,10 @@
|
|
1
1
|
# [develop](https://github.com/adhearsion/ruby_ami)
|
2
2
|
|
3
|
-
# [
|
4
|
-
*
|
3
|
+
# [2.0.0](https://github.com/adhearsion/ruby_ami/compare/v1.3.3...v2.0.0) - [2013-04-15](https://rubygems.org/gems/ruby_ami/versions/2.0.0)
|
4
|
+
* Major refactoring for simplification and performance
|
5
|
+
* Actions are no longer synchronised on the wire since ActionID is now a reliable method of response/event association
|
6
|
+
* Callbacks are no longer required. #send_action now simply blocks waiting for a response
|
7
|
+
* Client still starts up two Streams, one for actions and one for events, but only for possible performance gains. It is possible to use Stream directly since it now does its own login and response association. Client is a very thin routing layer. It's encouraged that if you expect low traffic, you should use Stream directly. Client may be removed in v3.0.
|
5
8
|
|
6
9
|
# [1.3.3](https://github.com/adhearsion/ruby_ami/compare/v1.3.2...v1.3.3) - [2013-04-09](https://rubygems.org/gems/ruby_ami/versions/1.3.3)
|
7
10
|
* Bugfix: DBGet actions are now not terminated specially
|
data/README.md
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
# RubyAMI [![Build Status](https://secure.travis-ci.org/adhearsion/ruby_ami.png?branch=master)](http://travis-ci.org/adhearsion/ruby_ami)
|
2
|
-
RubyAMI is an AMI client library in Ruby and based on EventMachine with the sole purpose of providing
|
2
|
+
RubyAMI is an AMI client library in Ruby and based on EventMachine with the sole purpose of providing a connection to the Asterisk Manager Interface. RubyAMI does not provide any features beyond connection management and protocol parsing. Actions are sent over the wire, and responses are returned. Events are passed to a callback you define. It's up to you to match these up into something useful. In this regard, RubyAMI is very similar to [Blather](https://github.com/sprsquish/blather) for XMPP or [Punchblock](https://github.com/adhearsion/punchblock), the Ruby 3PCC library. In fact, Punchblock uses RubyAMI under the covers for its Asterisk implementation, including an implementation of AsyncAGI.
|
3
3
|
|
4
4
|
NB: If you're looking to develop an application on Asterisk, you should take a look at the [Adhearsion](http://adhearsion.com) framework first. This library is much lower level.
|
5
5
|
|
@@ -10,31 +10,30 @@ NB: If you're looking to develop an application on Asterisk, you should take a l
|
|
10
10
|
```ruby
|
11
11
|
require 'ruby_ami'
|
12
12
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
:log_level => Logger::DEBUG,
|
22
|
-
:timeout => 10
|
13
|
+
client = RubyAMI::Client.new username: 'test',
|
14
|
+
password: 'test',
|
15
|
+
host: '127.0.0.1',
|
16
|
+
port: 5038,
|
17
|
+
event_handler: ->(e) { handle_event e },
|
18
|
+
logger: Logger.new(STDOUT),
|
19
|
+
log_level: Logger::DEBUG,
|
20
|
+
timeout: 10
|
23
21
|
|
24
22
|
def handle_event(event)
|
25
23
|
case event.name
|
26
24
|
when 'FullyBooted'
|
27
|
-
client.send_action 'Originate', 'Channel' => 'SIP/foo'
|
25
|
+
client.async.send_action 'Originate', 'Channel' => 'SIP/foo'
|
28
26
|
end
|
29
27
|
end
|
30
28
|
|
31
29
|
client.start
|
30
|
+
|
31
|
+
Celluloid::Actor.join client
|
32
32
|
```
|
33
33
|
|
34
34
|
## Development Requirements
|
35
35
|
|
36
|
-
ruby_ami uses [ragel](http://www.complang.org/ragel/) to generate some of it's
|
37
|
-
files.
|
36
|
+
ruby_ami uses [ragel](http://www.complang.org/ragel/) to generate some of it's files.
|
38
37
|
|
39
38
|
On OS X (if you use homebrew):
|
40
39
|
|
@@ -64,4 +63,4 @@ Once you are inside the repository, before anything else, you will want to run:
|
|
64
63
|
|
65
64
|
## Copyright
|
66
65
|
|
67
|
-
Copyright (c)
|
66
|
+
Copyright (c) 2013 Ben Langfeld, Jay Phillips. MIT licence (see LICENSE for details).
|
@@ -1,5 +1,23 @@
|
|
1
1
|
FIXTURES = YAML.load_file File.dirname(__FILE__) + "/ami_fixtures.yml"
|
2
2
|
|
3
|
+
class Object
|
4
|
+
def metaclass
|
5
|
+
class << self
|
6
|
+
self
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def meta_eval(&block)
|
11
|
+
metaclass.instance_eval &block
|
12
|
+
end
|
13
|
+
|
14
|
+
def meta_def(name, &block)
|
15
|
+
meta_eval do
|
16
|
+
define_method name, &block
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
3
21
|
def fixture(path, overrides = {})
|
4
22
|
path_segments = path.split '/'
|
5
23
|
selected_event = path_segments.inject(FIXTURES.clone) do |hash, segment|
|
data/lib/ruby_ami/action.rb
CHANGED
@@ -1,8 +1,6 @@
|
|
1
1
|
module RubyAMI
|
2
2
|
class Action
|
3
|
-
attr_reader :name, :headers, :action_id
|
4
|
-
|
5
|
-
attr_accessor :state
|
3
|
+
attr_reader :name, :headers, :action_id, :response
|
6
4
|
|
7
5
|
CAUSAL_EVENT_NAMES = %w[queuestatus sippeers iaxpeers parkedcalls dahdishowchannels coreshowchannels
|
8
6
|
dbget status agents konferencelist confbridgelist confbridgelistrooms] unless defined? CAUSAL_EVENT_NAMES
|
@@ -11,19 +9,14 @@ module RubyAMI
|
|
11
9
|
@name = name.to_s.downcase.freeze
|
12
10
|
@headers = headers.freeze
|
13
11
|
@action_id = RubyAMI.new_uuid
|
14
|
-
@response =
|
15
|
-
@
|
16
|
-
@state = :new
|
12
|
+
@response = nil
|
13
|
+
@complete = false
|
17
14
|
@events = []
|
18
|
-
@
|
19
|
-
end
|
20
|
-
|
21
|
-
[:new, :sent, :complete].each do |state|
|
22
|
-
define_method("#{state}?") { @state == state }
|
15
|
+
@callback = block
|
23
16
|
end
|
24
17
|
|
25
|
-
def
|
26
|
-
|
18
|
+
def complete?
|
19
|
+
@complete
|
27
20
|
end
|
28
21
|
|
29
22
|
##
|
@@ -71,46 +64,20 @@ module RubyAMI
|
|
71
64
|
)
|
72
65
|
end
|
73
66
|
|
74
|
-
#
|
75
|
-
# If the response has simply not been received yet from Asterisk, the calling Thread will block until it comes
|
76
|
-
# in. Once the response comes in, subsequent calls immediately return a reference to the ManagerInterfaceResponse
|
77
|
-
# object.
|
78
|
-
#
|
79
|
-
def response(timeout = nil)
|
80
|
-
@response.resource(timeout).tap do |resp|
|
81
|
-
raise resp if resp.is_a? Exception
|
82
|
-
end
|
83
|
-
end
|
84
|
-
|
85
|
-
def response=(other)
|
86
|
-
@state = :complete
|
87
|
-
@response.resource = other
|
88
|
-
@response_callback.call other if @response_callback
|
89
|
-
end
|
90
|
-
|
91
67
|
def <<(message)
|
92
68
|
case message
|
93
69
|
when Error
|
94
70
|
self.response = message
|
71
|
+
complete!
|
95
72
|
when Event
|
96
73
|
raise StandardError, 'This action should not trigger events. Maybe it is now a causal action? This is most likely a bug in RubyAMI' unless has_causal_events?
|
97
|
-
|
98
|
-
|
99
|
-
end
|
100
|
-
self.response = @pending_response if message.name.downcase == causal_event_terminator_name
|
74
|
+
response.events << message
|
75
|
+
complete! if message.name.downcase == causal_event_terminator_name
|
101
76
|
when Response
|
102
|
-
|
103
|
-
|
104
|
-
else
|
105
|
-
self.response = message
|
106
|
-
end
|
107
|
-
end
|
108
|
-
end
|
109
|
-
|
110
|
-
def events
|
111
|
-
@event_lock.synchronize do
|
112
|
-
@events.dup
|
77
|
+
self.response = message
|
78
|
+
complete! unless has_causal_events?
|
113
79
|
end
|
80
|
+
self
|
114
81
|
end
|
115
82
|
|
116
83
|
def eql?(other)
|
@@ -118,28 +85,15 @@ module RubyAMI
|
|
118
85
|
end
|
119
86
|
alias :== :eql?
|
120
87
|
|
121
|
-
|
122
|
-
name.downcase == 'originate' && !headers[:async] ? 60 : 10
|
123
|
-
end
|
88
|
+
private
|
124
89
|
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
class UnsupportedActionName < ArgumentError
|
129
|
-
UNSUPPORTED_ACTION_NAMES = %w[queues] unless defined? UNSUPPORTED_ACTION_NAMES
|
130
|
-
|
131
|
-
# Blacklist some actions depends on the Asterisk version
|
132
|
-
def self.preinitialize(version)
|
133
|
-
if version < 1.8
|
134
|
-
%w[iaxpeers muteaudio mixmonitormute aocmessage].each do |action|
|
135
|
-
UNSUPPORTED_ACTION_NAMES << action
|
136
|
-
end
|
137
|
-
end
|
138
|
-
end
|
90
|
+
def response=(other)
|
91
|
+
@response = other
|
92
|
+
end
|
139
93
|
|
140
|
-
|
141
|
-
|
142
|
-
|
94
|
+
def complete!
|
95
|
+
@complete = true
|
96
|
+
@callback.call response if @callback
|
143
97
|
end
|
144
98
|
end
|
145
99
|
end # RubyAMI
|
@@ -4,7 +4,7 @@ module RubyAMI
|
|
4
4
|
class AGIResultParser
|
5
5
|
attr_reader :code, :result, :data
|
6
6
|
|
7
|
-
FORMAT = /^(?<code>\d{3})
|
7
|
+
FORMAT = /^(?<code>\d{3}) result=(?<result>-?\d*) ?(?<data>\(?.*\)?)?$/.freeze
|
8
8
|
DATA_KV_FORMAT = /(?<key>[\w\d]+)=(?<value>[\w\d]*)/.freeze
|
9
9
|
DATA_CLEANER = /(^\()|(\)$)/.freeze
|
10
10
|
|
@@ -31,7 +31,7 @@ module RubyAMI
|
|
31
31
|
|
32
32
|
def parse
|
33
33
|
@code = match[:code].to_i
|
34
|
-
@result = match[:result]
|
34
|
+
@result = match[:result].to_i
|
35
35
|
@data = match[:data] ? match[:data].gsub(DATA_CLEANER, '').freeze : nil
|
36
36
|
end
|
37
37
|
|
data/lib/ruby_ami/client.rb
CHANGED
@@ -1,44 +1,19 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
module RubyAMI
|
3
3
|
class Client
|
4
|
-
|
4
|
+
include Celluloid
|
5
5
|
|
6
|
-
|
7
|
-
@options = options
|
8
|
-
@logger = options[:logger] || Logger.new(STDOUT)
|
9
|
-
@logger.level = options[:log_level] || Logger::DEBUG if @logger
|
10
|
-
@event_handler = @options[:event_handler]
|
11
|
-
@state = :stopped
|
12
|
-
|
13
|
-
if RubyAMI.rbx?
|
14
|
-
logger.warn 'The "timeout" parameter is not supported when using Rubinius'
|
15
|
-
end
|
16
|
-
|
17
|
-
stop_writing_actions
|
18
|
-
|
19
|
-
@pending_actions = {}
|
20
|
-
@sent_actions = {}
|
21
|
-
@actions_lock = Mutex.new
|
22
|
-
|
23
|
-
@action_queue = GirlFriday::WorkQueue.new(:actions, :size => 1, :error_handler => ErrorHandler) do |action|
|
24
|
-
@actions_write_blocker.wait
|
25
|
-
_send_action action
|
26
|
-
begin
|
27
|
-
action.response action.sync_timeout
|
28
|
-
rescue Timeout::Error => e
|
29
|
-
logger.error "Timed out waiting for a response to #{action}"
|
30
|
-
rescue RubyAMI::Error
|
31
|
-
nil
|
32
|
-
end
|
33
|
-
end
|
6
|
+
trap_exit :stream_died
|
34
7
|
|
35
|
-
|
36
|
-
handle_message message
|
37
|
-
end
|
8
|
+
attr_reader :events_stream, :actions_stream
|
38
9
|
|
39
|
-
|
40
|
-
|
41
|
-
|
10
|
+
def initialize(options)
|
11
|
+
@options = options
|
12
|
+
@event_handler = @options[:event_handler]
|
13
|
+
@state = :stopped
|
14
|
+
client = current_actor
|
15
|
+
@events_stream = new_stream ->(event) { client.async.handle_event event }
|
16
|
+
@actions_stream = new_stream ->(message) { client.async.handle_message message }
|
42
17
|
end
|
43
18
|
|
44
19
|
[:started, :stopped, :ready].each do |state|
|
@@ -46,162 +21,56 @@ module RubyAMI
|
|
46
21
|
end
|
47
22
|
|
48
23
|
def start
|
49
|
-
@events_stream
|
50
|
-
@actions_stream
|
51
|
-
streams.each { |stream| stream.async.run }
|
24
|
+
@events_stream.async.run
|
25
|
+
@actions_stream.async.run
|
52
26
|
@state = :started
|
53
|
-
streams.each { |s| Celluloid::Actor.join s }
|
54
|
-
end
|
55
|
-
|
56
|
-
def stop
|
57
|
-
streams.each do |stream|
|
58
|
-
begin
|
59
|
-
stream.terminate if stream.alive?
|
60
|
-
rescue => e
|
61
|
-
logger.error e if logger
|
62
|
-
end
|
63
|
-
end
|
64
27
|
end
|
65
28
|
|
66
|
-
def send_action(
|
67
|
-
|
68
|
-
logger.trace "[QUEUE]: #{action.inspect}" if logger
|
69
|
-
register_pending_action action
|
70
|
-
action_queue << action
|
71
|
-
end
|
29
|
+
def send_action(*args)
|
30
|
+
actions_stream.send_action *args
|
72
31
|
end
|
73
32
|
|
74
33
|
def handle_message(message)
|
75
|
-
logger.trace "[RECV-ACTIONS]: #{message.inspect}"
|
34
|
+
logger.trace "[RECV-ACTIONS]: #{message.inspect}"
|
76
35
|
case message
|
77
36
|
when Stream::Connected
|
78
|
-
|
37
|
+
send_action 'Events', 'EventMask' => 'Off'
|
79
38
|
when Stream::Disconnected
|
80
|
-
stop_writing_actions
|
81
|
-
stop
|
82
39
|
when Event
|
83
|
-
|
84
|
-
if action
|
85
|
-
message.action = action
|
86
|
-
action << message
|
87
|
-
@current_action_with_causal_events = nil if action.complete?
|
88
|
-
else
|
89
|
-
if message.name == 'FullyBooted'
|
90
|
-
pass_event message
|
91
|
-
start_writing_actions
|
92
|
-
else
|
93
|
-
raise StandardError, "Got an unexpected event on actions socket! This AMI command may have a multi-message response. Try making Adhearsion treat it as causal action #{message.inspect}"
|
94
|
-
end
|
95
|
-
end
|
96
|
-
when Response, Error
|
97
|
-
action = sent_action_with_id message.action_id
|
98
|
-
raise StandardError, "Received an AMI response with an unrecognized ActionID!! This may be an bug! #{message.inspect}" unless action
|
99
|
-
message.action = action
|
100
|
-
|
101
|
-
# By this point the write loop will already have started blocking by calling the response() method on the
|
102
|
-
# action. Because we must collect more events before we wake the write loop up again, let's create these
|
103
|
-
# instance variable which will needed when the subsequent causal events come in.
|
104
|
-
@current_action_with_causal_events = action if action.has_causal_events?
|
105
|
-
|
106
|
-
action << message
|
40
|
+
pass_event message
|
107
41
|
end
|
108
42
|
end
|
109
43
|
|
110
44
|
def handle_event(event)
|
111
|
-
logger.trace "[RECV-EVENTS]: #{event.inspect}"
|
45
|
+
logger.trace "[RECV-EVENTS]: #{event.inspect}"
|
112
46
|
case event
|
113
|
-
when Stream::Connected
|
114
|
-
login_events
|
115
|
-
when Stream::Disconnected
|
116
|
-
stop
|
47
|
+
when Stream::Connected, Stream::Disconnected
|
117
48
|
else
|
118
49
|
pass_event event
|
119
50
|
end
|
120
51
|
end
|
121
52
|
|
122
|
-
def _send_action(action)
|
123
|
-
logger.trace "[SEND]: #{action.inspect}" if logger
|
124
|
-
transition_action_to_sent action
|
125
|
-
actions_stream.send_action action
|
126
|
-
action.state = :sent
|
127
|
-
action
|
128
|
-
end
|
129
|
-
|
130
53
|
private
|
131
54
|
|
132
55
|
def pass_event(event)
|
133
56
|
@event_handler.call event if @event_handler.respond_to? :call
|
134
57
|
end
|
135
58
|
|
136
|
-
def
|
137
|
-
|
138
|
-
@pending_actions[action.action_id] = action
|
139
|
-
end
|
140
|
-
end
|
141
|
-
|
142
|
-
def transition_action_to_sent(action)
|
143
|
-
@actions_lock.synchronize do
|
144
|
-
@pending_actions.delete action.action_id
|
145
|
-
@sent_actions[action.action_id] = action
|
146
|
-
end
|
147
|
-
end
|
148
|
-
|
149
|
-
def sent_action_with_id(action_id)
|
150
|
-
@actions_lock.synchronize do
|
151
|
-
@sent_actions.delete action_id
|
152
|
-
end
|
153
|
-
end
|
154
|
-
|
155
|
-
def start_writing_actions
|
156
|
-
@actions_write_blocker.countdown!
|
157
|
-
end
|
158
|
-
|
159
|
-
def stop_writing_actions
|
160
|
-
@actions_write_blocker = CountDownLatch.new 1
|
161
|
-
end
|
162
|
-
|
163
|
-
def login_actions
|
164
|
-
action = login_action do |response|
|
165
|
-
pass_event response if response.is_a? Error
|
166
|
-
send_action 'Events', 'EventMask' => 'Off'
|
167
|
-
end
|
168
|
-
|
169
|
-
register_pending_action action
|
170
|
-
Thread.new { _send_action action }
|
171
|
-
end
|
172
|
-
|
173
|
-
def login_events
|
174
|
-
login_action.tap do |action|
|
175
|
-
events_stream.send_action action
|
176
|
-
end
|
177
|
-
end
|
178
|
-
|
179
|
-
def login_action(&block)
|
180
|
-
Action.new 'Login',
|
181
|
-
'Username' => options[:username],
|
182
|
-
'Secret' => options[:password],
|
183
|
-
'Events' => 'On',
|
184
|
-
&block
|
59
|
+
def new_stream(callback)
|
60
|
+
Stream.new_link @options[:host], @options[:port], @options[:username], @options[:password], callback, logger, @options[:timeout]
|
185
61
|
end
|
186
62
|
|
187
|
-
def
|
188
|
-
|
63
|
+
def stream_died(stream, reason = nil)
|
64
|
+
terminate
|
189
65
|
end
|
190
66
|
|
191
67
|
def logger
|
192
68
|
super
|
193
|
-
rescue
|
194
|
-
@logger
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
[actions_stream, events_stream].compact
|
199
|
-
end
|
200
|
-
|
201
|
-
class ErrorHandler
|
202
|
-
def handle(error)
|
203
|
-
puts error.message
|
204
|
-
puts error.backtrace.join("\n")
|
69
|
+
rescue
|
70
|
+
@logger ||= begin
|
71
|
+
logger = Logger
|
72
|
+
logger.define_singleton_method :trace, logger.method(:debug)
|
73
|
+
logger
|
205
74
|
end
|
206
75
|
end
|
207
76
|
end
|
data/lib/ruby_ami/error.rb
CHANGED
data/lib/ruby_ami/event.rb
CHANGED
data/lib/ruby_ami/response.rb
CHANGED
@@ -3,8 +3,6 @@ module RubyAMI
|
|
3
3
|
##
|
4
4
|
# This is the object containing a response from Asterisk.
|
5
5
|
#
|
6
|
-
# Note: not all responses have an ActionID!
|
7
|
-
#
|
8
6
|
class Response
|
9
7
|
class << self
|
10
8
|
def from_immediate_response(text)
|
@@ -14,12 +12,12 @@ module RubyAMI
|
|
14
12
|
end
|
15
13
|
end
|
16
14
|
|
17
|
-
attr_accessor :
|
18
|
-
:
|
19
|
-
attr_reader :events
|
15
|
+
attr_accessor :text_body, # For "Response: Follows" sections
|
16
|
+
:events
|
20
17
|
|
21
|
-
def initialize
|
22
|
-
@headers =
|
18
|
+
def initialize(headers = {})
|
19
|
+
@headers = headers
|
20
|
+
@events = []
|
23
21
|
end
|
24
22
|
|
25
23
|
def has_text_body?
|
@@ -47,7 +45,7 @@ module RubyAMI
|
|
47
45
|
end
|
48
46
|
|
49
47
|
def inspect_attributes
|
50
|
-
[:headers, :text_body, :events
|
48
|
+
[:headers, :text_body, :events]
|
51
49
|
end
|
52
50
|
|
53
51
|
def eql?(o, *fields)
|