redcord 0.0.2.alpha → 0.1.2
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/lib/redcord.rb +30 -2
- data/lib/redcord.rbi +0 -16
- data/lib/redcord/actions.rb +152 -45
- data/lib/redcord/attribute.rb +110 -13
- data/lib/redcord/base.rb +17 -3
- data/lib/redcord/configurations.rb +4 -0
- data/lib/redcord/logger.rb +1 -1
- data/lib/redcord/migration.rb +2 -0
- data/lib/redcord/migration/index.rb +57 -0
- data/lib/redcord/migration/ttl.rb +9 -4
- data/lib/redcord/railtie.rb +18 -0
- data/lib/redcord/redis.rb +200 -0
- data/lib/redcord/redis_connection.rb +16 -25
- data/lib/redcord/relation.rb +141 -33
- data/lib/redcord/serializer.rb +84 -33
- data/lib/redcord/server_scripts/create_hash.erb.lua +81 -0
- data/lib/redcord/server_scripts/delete_hash.erb.lua +17 -8
- data/lib/redcord/server_scripts/find_by_attr.erb.lua +51 -16
- data/lib/redcord/server_scripts/find_by_attr_count.erb.lua +45 -14
- data/lib/redcord/server_scripts/shared/index_helper_methods.erb.lua +45 -16
- data/lib/redcord/server_scripts/shared/lua_helper_methods.erb.lua +20 -4
- data/lib/redcord/server_scripts/shared/query_helper_methods.erb.lua +81 -14
- data/lib/redcord/server_scripts/update_hash.erb.lua +40 -26
- data/lib/redcord/tasks/redis.rake +15 -0
- data/lib/redcord/tracer.rb +48 -0
- data/lib/redcord/vacuum_helper.rb +90 -0
- metadata +9 -8
- data/lib/redcord/prepared_redis.rb +0 -18
- data/lib/redcord/server_scripts.rb +0 -78
- data/lib/redcord/server_scripts/create_hash_returning_id.erb.lua +0 -69
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c0c8f566eb89b44bd73c4a444deb313af1004a342a703a0e9f504364aa96df40
|
4
|
+
data.tar.gz: e77507ef1ebb3a1d2a6ba9f0fc654d25355f5bc685b05e4bc07368957bdc7ec3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 27d9afec9f1a663516b65096439312bdc313aed672c6476bcf38d0ce98cd9178bebf3b5a46341d69ad6fc6f3f4268655956c6e5170af2f7da8489d68e343dee5
|
7
|
+
data.tar.gz: 1b011be75251942091acc209189f33d72ff289496a07f5700c140a06916f83ad310e1e804fc3bd60d77815f89b57fd6a66b9efe2d7fabd81d32dfca7f281b7d6
|
data/lib/redcord.rb
CHANGED
@@ -1,11 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# typed: strict
|
2
|
-
module Redcord
|
3
|
-
end
|
4
4
|
|
5
5
|
require 'sorbet-runtime'
|
6
6
|
|
7
|
+
module Redcord
|
8
|
+
extend T::Sig
|
9
|
+
|
10
|
+
@@configuration_blks = T.let(
|
11
|
+
[],
|
12
|
+
T::Array[T.proc.params(arg0: T.untyped).void],
|
13
|
+
)
|
14
|
+
|
15
|
+
sig {
|
16
|
+
params(
|
17
|
+
blk: T.proc.params(arg0: T.untyped).void,
|
18
|
+
).void
|
19
|
+
}
|
20
|
+
def self.configure(&blk)
|
21
|
+
@@configuration_blks << blk
|
22
|
+
end
|
23
|
+
|
24
|
+
sig { void }
|
25
|
+
def self._after_initialize!
|
26
|
+
@@configuration_blks.each do |blk|
|
27
|
+
blk.call(Redcord::Base)
|
28
|
+
end
|
29
|
+
|
30
|
+
@@configuration_blks.clear
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
7
34
|
require 'redcord/base'
|
8
35
|
require 'redcord/migration'
|
9
36
|
require 'redcord/migration/migrator'
|
10
37
|
require 'redcord/migration/version'
|
11
38
|
require 'redcord/railtie'
|
39
|
+
require 'redcord/vacuum_helper'
|
data/lib/redcord.rbi
CHANGED
@@ -45,22 +45,6 @@ module Redcord::TTL::ClassMethods
|
|
45
45
|
include Redcord::Serializer::ClassMethods
|
46
46
|
end
|
47
47
|
|
48
|
-
module Redcord::ServerScripts
|
49
|
-
include Kernel
|
50
|
-
|
51
|
-
sig do
|
52
|
-
params(
|
53
|
-
sha: String,
|
54
|
-
keys: T::Array[T.untyped],
|
55
|
-
argv: T::Array[T.untyped],
|
56
|
-
).returns(T.untyped)
|
57
|
-
end
|
58
|
-
def evalsha(sha, keys: [], argv:[]); end
|
59
|
-
|
60
|
-
sig { returns(T::Hash[Symbol, String]) }
|
61
|
-
def redcord_server_script_shas; end
|
62
|
-
end
|
63
|
-
|
64
48
|
module Redcord::Actions::ClassMethods
|
65
49
|
include Kernel
|
66
50
|
include Redcord::RedisConnection::ClassMethods
|
data/lib/redcord/actions.rb
CHANGED
@@ -9,6 +9,7 @@ require 'redcord/relation'
|
|
9
9
|
module Redcord
|
10
10
|
# Raised by Model.find
|
11
11
|
class RecordNotFound < StandardError; end
|
12
|
+
class InvalidAction < StandardError; end
|
12
13
|
end
|
13
14
|
|
14
15
|
module Redcord::Actions
|
@@ -24,29 +25,65 @@ module Redcord::Actions
|
|
24
25
|
module ClassMethods
|
25
26
|
extend T::Sig
|
26
27
|
|
28
|
+
sig { returns(Integer) }
|
29
|
+
def count
|
30
|
+
Redcord::Base.trace(
|
31
|
+
'redcord_actions_class_methods_count',
|
32
|
+
model_name: name,
|
33
|
+
) do
|
34
|
+
res = 0
|
35
|
+
redis.scan_each_shard("#{model_key}:id:*") { res += 1 }
|
36
|
+
res
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
27
40
|
sig { params(args: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
|
28
41
|
def create!(args)
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
42
|
+
Redcord::Base.trace(
|
43
|
+
'redcord_actions_class_methods_create!',
|
44
|
+
model_name: name,
|
45
|
+
) do
|
46
|
+
self.props.keys.each { |attr_key| args[attr_key] = nil unless args.key?(attr_key) }
|
47
|
+
args[:created_at] = args[:updated_at] = Time.zone.now
|
48
|
+
instance = TypeCoerce[self].new.from(args)
|
49
|
+
id = redis.create_hash_returning_id(
|
50
|
+
model_key,
|
51
|
+
to_redis_hash(args),
|
52
|
+
ttl: _script_arg_ttl,
|
53
|
+
index_attrs: _script_arg_index_attrs,
|
54
|
+
range_index_attrs: _script_arg_range_index_attrs,
|
55
|
+
custom_index_attrs: _script_arg_custom_index_attrs,
|
56
|
+
hash_tag: instance.hash_tag,
|
57
|
+
)
|
58
|
+
instance.send(:id=, id)
|
59
|
+
instance
|
60
|
+
end
|
34
61
|
end
|
35
62
|
|
36
63
|
sig { params(id: T.untyped).returns(T.untyped) }
|
37
64
|
def find(id)
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
65
|
+
Redcord::Base.trace(
|
66
|
+
'redcord_actions_class_methods_find',
|
67
|
+
model_name: name,
|
68
|
+
) do
|
69
|
+
instance_key = "#{model_key}:id:#{id}"
|
70
|
+
args = redis.hgetall(instance_key)
|
71
|
+
if args.empty?
|
72
|
+
raise Redcord::RecordNotFound, "Couldn't find #{name} with 'id'=#{id}"
|
73
|
+
end
|
43
74
|
|
44
|
-
|
75
|
+
coerce_and_set_id(args, id)
|
76
|
+
end
|
45
77
|
end
|
46
78
|
|
47
79
|
sig { params(args: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
|
48
80
|
def find_by(args)
|
49
|
-
|
81
|
+
Redcord::Base.trace(
|
82
|
+
'redcord_actions_class_methods_find_by_args',
|
83
|
+
model_name: name,
|
84
|
+
) do
|
85
|
+
where(args).to_a.first
|
86
|
+
end
|
50
87
|
end
|
51
88
|
|
52
89
|
sig { params(args: T::Hash[Symbol, T.untyped]).returns(Redcord::Relation) }
|
@@ -56,7 +93,18 @@ module Redcord::Actions
|
|
56
93
|
|
57
94
|
sig { params(id: T.untyped).returns(T::Boolean) }
|
58
95
|
def destroy(id)
|
59
|
-
|
96
|
+
Redcord::Base.trace(
|
97
|
+
'redcord_actions_class_methods_destroy',
|
98
|
+
model_name: name,
|
99
|
+
) do
|
100
|
+
redis.delete_hash(
|
101
|
+
model_key,
|
102
|
+
id,
|
103
|
+
index_attrs: _script_arg_index_attrs,
|
104
|
+
range_index_attrs: _script_arg_range_index_attrs,
|
105
|
+
custom_index_attrs: _script_arg_custom_index_attrs,
|
106
|
+
) == 1
|
107
|
+
end
|
60
108
|
end
|
61
109
|
end
|
62
110
|
|
@@ -88,46 +136,105 @@ module Redcord::Actions
|
|
88
136
|
|
89
137
|
sig { void }
|
90
138
|
def save!
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
self.
|
103
|
-
_id
|
104
|
-
|
105
|
-
|
139
|
+
Redcord::Base.trace(
|
140
|
+
'redcord_actions_instance_methods_save!',
|
141
|
+
model_name: self.class.name,
|
142
|
+
) do
|
143
|
+
self.updated_at = Time.zone.now
|
144
|
+
_id = id
|
145
|
+
if _id.nil?
|
146
|
+
serialized_instance = serialize
|
147
|
+
self.class.props.keys.each do |attr_key|
|
148
|
+
serialized_instance[attr_key.to_s] = nil unless serialized_instance.key?(attr_key.to_s)
|
149
|
+
end
|
150
|
+
self.created_at = T.must(self.updated_at)
|
151
|
+
_id = redis.create_hash_returning_id(
|
152
|
+
self.class.model_key,
|
153
|
+
self.class.to_redis_hash(serialized_instance),
|
154
|
+
ttl: self.class._script_arg_ttl,
|
155
|
+
index_attrs: self.class._script_arg_index_attrs,
|
156
|
+
range_index_attrs: self.class._script_arg_range_index_attrs,
|
157
|
+
custom_index_attrs: self.class._script_arg_custom_index_attrs,
|
158
|
+
hash_tag: hash_tag,
|
159
|
+
)
|
160
|
+
send(:id=, _id)
|
161
|
+
else
|
162
|
+
redis.update_hash(
|
163
|
+
self.class.model_key,
|
164
|
+
_id,
|
165
|
+
self.class.to_redis_hash(serialize),
|
166
|
+
ttl: self.class._script_arg_ttl,
|
167
|
+
index_attrs: self.class._script_arg_index_attrs,
|
168
|
+
range_index_attrs: self.class._script_arg_range_index_attrs,
|
169
|
+
custom_index_attrs: self.class._script_arg_custom_index_attrs,
|
170
|
+
hash_tag: hash_tag,
|
171
|
+
)
|
172
|
+
end
|
106
173
|
end
|
107
174
|
end
|
108
175
|
|
176
|
+
sig { returns(T::Boolean) }
|
177
|
+
def save
|
178
|
+
save!
|
179
|
+
|
180
|
+
true
|
181
|
+
rescue Redis::CommandError
|
182
|
+
# TODO: break down Redis::CommandError by parsing the error message
|
183
|
+
false
|
184
|
+
end
|
185
|
+
|
109
186
|
sig { params(args: T::Hash[Symbol, T.untyped]).void }
|
110
|
-
def update!(args
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
args
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
187
|
+
def update!(args)
|
188
|
+
Redcord::Base.trace(
|
189
|
+
'redcord_actions_instance_methods_update!',
|
190
|
+
model_name: self.class.name,
|
191
|
+
) do
|
192
|
+
shard_by_attr = self.class.shard_by_attribute
|
193
|
+
if args.keys.include?(shard_by_attr)
|
194
|
+
raise Redcord::InvalidAction, "Cannot update shard_by attribute #{shard_by_attr}"
|
195
|
+
end
|
196
|
+
|
197
|
+
_id = id
|
198
|
+
if _id.nil?
|
199
|
+
_set_args!(args)
|
200
|
+
save!
|
201
|
+
else
|
202
|
+
args[:updated_at] = Time.zone.now
|
203
|
+
_set_args!(args)
|
204
|
+
redis.update_hash(
|
205
|
+
self.class.model_key,
|
206
|
+
_id,
|
207
|
+
self.class.to_redis_hash(args),
|
208
|
+
ttl: self.class._script_arg_ttl,
|
209
|
+
index_attrs: self.class._script_arg_index_attrs,
|
210
|
+
range_index_attrs: self.class._script_arg_range_index_attrs,
|
211
|
+
custom_index_attrs: self.class._script_arg_custom_index_attrs,
|
212
|
+
hash_tag: hash_tag,
|
213
|
+
)
|
214
|
+
end
|
123
215
|
end
|
124
216
|
end
|
125
217
|
|
218
|
+
sig { params(args: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
|
219
|
+
def update(args)
|
220
|
+
update!(args)
|
221
|
+
|
222
|
+
true
|
223
|
+
rescue Redis::CommandError
|
224
|
+
# TODO: break down Redis::CommandError by parsing the error message
|
225
|
+
false
|
226
|
+
end
|
227
|
+
|
126
228
|
sig { returns(T::Boolean) }
|
127
229
|
def destroy
|
128
|
-
|
230
|
+
Redcord::Base.trace(
|
231
|
+
'redcord_actions_instance_methods_destroy',
|
232
|
+
model_name: self.class.name,
|
233
|
+
) do
|
234
|
+
return false if id.nil?
|
129
235
|
|
130
|
-
|
236
|
+
self.class.destroy(T.must(id))
|
237
|
+
end
|
131
238
|
end
|
132
239
|
|
133
240
|
sig { returns(String) }
|
@@ -142,14 +249,14 @@ module Redcord::Actions
|
|
142
249
|
end
|
143
250
|
end
|
144
251
|
|
145
|
-
sig { returns(T.nilable(
|
252
|
+
sig { returns(T.nilable(String)) }
|
146
253
|
def id
|
147
254
|
instance_variable_get(:@_id)
|
148
255
|
end
|
149
256
|
|
150
257
|
private
|
151
258
|
|
152
|
-
sig { params(id:
|
259
|
+
sig { params(id: String).returns(String) }
|
153
260
|
def id=(id)
|
154
261
|
instance_variable_set(:@_id, id)
|
155
262
|
end
|
data/lib/redcord/attribute.rb
CHANGED
@@ -1,6 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
# typed: strict
|
4
|
+
module Redcord
|
5
|
+
class InvalidAttribute < StandardError; end
|
6
|
+
end
|
4
7
|
|
5
8
|
module Redcord::Attribute
|
6
9
|
extend T::Sig
|
@@ -10,18 +13,33 @@ module Redcord::Attribute
|
|
10
13
|
# type.
|
11
14
|
RangeIndexType = T.type_alias {
|
12
15
|
T.any(
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
+
Float,
|
17
|
+
Integer,
|
18
|
+
NilClass,
|
19
|
+
Numeric,
|
20
|
+
Time,
|
16
21
|
)
|
17
22
|
}
|
18
23
|
|
24
|
+
# Implicitly determine what data type can be a used in custom index on Redis based on Ruby type.
|
25
|
+
# Custom index currently supports positive integers with up to 19 characters in decimal notation,
|
26
|
+
# will raise error in Lua if bigger numbers are used.
|
27
|
+
CustomIndexType = T.type_alias {
|
28
|
+
T.any(
|
29
|
+
Integer,
|
30
|
+
Time,
|
31
|
+
)
|
32
|
+
}
|
33
|
+
|
19
34
|
sig { params(klass: T.class_of(T::Struct)).void }
|
20
35
|
def self.included(klass)
|
21
36
|
klass.extend(ClassMethods)
|
37
|
+
klass.include(InstanceMethods)
|
22
38
|
klass.class_variable_set(:@@index_attributes, Set.new)
|
23
39
|
klass.class_variable_set(:@@range_index_attributes, Set.new)
|
40
|
+
klass.class_variable_set(:@@custom_index_attributes, Hash.new { |h, k| h[k] = [] })
|
24
41
|
klass.class_variable_set(:@@ttl, nil)
|
42
|
+
klass.class_variable_set(:@@shard_by_attribute, nil)
|
25
43
|
end
|
26
44
|
|
27
45
|
module ClassMethods
|
@@ -46,30 +64,81 @@ module Redcord::Attribute
|
|
46
64
|
def index_attribute(attr, type)
|
47
65
|
if should_range_index?(type)
|
48
66
|
class_variable_get(:@@range_index_attributes) << attr
|
49
|
-
sadd_proc_on_redis_connection('range_index_attrs', attr.to_s)
|
50
67
|
else
|
51
68
|
class_variable_get(:@@index_attributes) << attr
|
52
|
-
sadd_proc_on_redis_connection('index_attrs', attr.to_s)
|
53
69
|
end
|
54
70
|
end
|
71
|
+
|
72
|
+
sig { params(index_name: Symbol, attrs: T::Array[Symbol]).void }
|
73
|
+
def custom_index(index_name, attrs)
|
74
|
+
attrs.each do |attr|
|
75
|
+
type = props[attr][:type]
|
76
|
+
if !can_custom_index?(type)
|
77
|
+
raise(Redcord::WrongAttributeType, "Custom index doesn't support '#{type}' attributes.")
|
78
|
+
end
|
79
|
+
end
|
80
|
+
shard_by_attr = class_variable_get(:@@shard_by_attribute)
|
81
|
+
if shard_by_attr and shard_by_attr != attrs.first
|
82
|
+
raise(
|
83
|
+
Redcord::CustomIndexInvalidDesign,
|
84
|
+
"shard_by attribute '#{shard_by_attr}' must be placed first in '#{index_name}' index"
|
85
|
+
)
|
86
|
+
end
|
87
|
+
class_variable_get(:@@custom_index_attributes)[index_name] = attrs
|
88
|
+
end
|
55
89
|
|
56
90
|
sig { params(duration: T.nilable(ActiveSupport::Duration)).void }
|
57
91
|
def ttl(duration)
|
58
92
|
class_variable_set(:@@ttl, duration)
|
59
93
|
end
|
60
94
|
|
61
|
-
|
95
|
+
def shard_by_attribute(attr=nil)
|
96
|
+
return class_variable_get(:@@shard_by_attribute) if attr.nil?
|
62
97
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
# migrations
|
68
|
-
Redcord::RedisConnection.procs_to_prepare << proc do |redis|
|
69
|
-
redis.sadd("#{model_key}:#{redis_key}", item_to_add)
|
98
|
+
# attr must be an non-index attribute (index: false)
|
99
|
+
if class_variable_get(:@@index_attributes).include?(attr) ||
|
100
|
+
class_variable_get(:@@range_index_attributes).include?(attr)
|
101
|
+
raise Redcord::InvalidAttribute, "Cannot shard by an index attribute '#{attr}'"
|
70
102
|
end
|
103
|
+
|
104
|
+
class_variable_get(:@@custom_index_attributes).each do |index_name, attrs|
|
105
|
+
if attr != attrs.first
|
106
|
+
raise(
|
107
|
+
Redcord::CustomIndexInvalidDesign,
|
108
|
+
"shard_by attribute '#{attr}' must be placed first in '#{index_name}' index"
|
109
|
+
)
|
110
|
+
end
|
111
|
+
|
112
|
+
# Delete the shard_by_attribute since it would be a constant in the
|
113
|
+
# custom index set
|
114
|
+
attrs.shift
|
115
|
+
end
|
116
|
+
|
117
|
+
class_variable_set(:@@shard_by_attribute, attr)
|
71
118
|
end
|
72
119
|
|
120
|
+
sig { returns(Integer) }
|
121
|
+
def _script_arg_ttl
|
122
|
+
class_variable_get(:@@ttl)&.to_i || -1
|
123
|
+
end
|
124
|
+
|
125
|
+
sig { returns(T::Array[Symbol]) }
|
126
|
+
def _script_arg_index_attrs
|
127
|
+
class_variable_get(:@@index_attributes).to_a
|
128
|
+
end
|
129
|
+
|
130
|
+
sig { returns(T::Array[Symbol]) }
|
131
|
+
def _script_arg_range_index_attrs
|
132
|
+
class_variable_get(:@@range_index_attributes).to_a
|
133
|
+
end
|
134
|
+
|
135
|
+
sig { returns(T::Hash[Symbol, T::Array]) }
|
136
|
+
def _script_arg_custom_index_attrs
|
137
|
+
class_variable_get(:@@custom_index_attributes)
|
138
|
+
end
|
139
|
+
|
140
|
+
private
|
141
|
+
|
73
142
|
sig { params(type: T.any(Class, T::Types::Base)).returns(T::Boolean) }
|
74
143
|
def should_range_index?(type)
|
75
144
|
# Change Ruby raw type to Sorbet type in order to call subtype_of?
|
@@ -77,6 +146,34 @@ module Redcord::Attribute
|
|
77
146
|
|
78
147
|
type.subtype_of?(RangeIndexType)
|
79
148
|
end
|
149
|
+
|
150
|
+
sig { params(type: T.any(Class, T::Types::Base)).returns(T::Boolean) }
|
151
|
+
def can_custom_index?(type)
|
152
|
+
# Change Ruby raw type to Sorbet type in order to call subtype_of?
|
153
|
+
type = T::Types::Simple.new(type) if type.is_a?(Class)
|
154
|
+
type.subtype_of?(CustomIndexType)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
module InstanceMethods
|
159
|
+
extend T::Sig
|
160
|
+
|
161
|
+
sig { returns(T.nilable(String)) }
|
162
|
+
def hash_tag
|
163
|
+
attr = self.class.class_variable_get(:@@shard_by_attribute)
|
164
|
+
|
165
|
+
return nil if attr.nil?
|
166
|
+
|
167
|
+
# A blank hash tag would cause MOVED error in cluster mode
|
168
|
+
tag = send(attr)
|
169
|
+
default_tag = '__redcord_hash_tag_null__'
|
170
|
+
|
171
|
+
if tag == default_tag
|
172
|
+
raise Redcord::InvalidAttribute, "#{attr}=#{default_tag} conflicts with default hash_tag value"
|
173
|
+
end
|
174
|
+
|
175
|
+
"{#{tag || default_tag}}"
|
176
|
+
end
|
80
177
|
end
|
81
178
|
|
82
179
|
mixes_in_class_methods(ClassMethods)
|