redis_object 0.5.0
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.
- data/.coveralls.yml +1 -0
- data/.gitignore +6 -0
- data/.travis.yml +5 -0
- data/Gemfile +8 -0
- data/README.markdown +179 -0
- data/Rakefile +10 -0
- data/lib/redis_object.rb +47 -0
- data/lib/redis_object/base.rb +408 -0
- data/lib/redis_object/collection.rb +388 -0
- data/lib/redis_object/defaults.rb +42 -0
- data/lib/redis_object/experimental/history.rb +49 -0
- data/lib/redis_object/ext/benchmark.rb +34 -0
- data/lib/redis_object/ext/cleaner.rb +14 -0
- data/lib/redis_object/ext/filters.rb +68 -0
- data/lib/redis_object/ext/script_cache.rb +92 -0
- data/lib/redis_object/ext/shardable.rb +18 -0
- data/lib/redis_object/ext/triggers.rb +101 -0
- data/lib/redis_object/ext/view_caching.rb +258 -0
- data/lib/redis_object/ext/views.rb +102 -0
- data/lib/redis_object/external_index.rb +25 -0
- data/lib/redis_object/indices.rb +97 -0
- data/lib/redis_object/inheritance_tracking.rb +23 -0
- data/lib/redis_object/keys.rb +37 -0
- data/lib/redis_object/storage.rb +93 -0
- data/lib/redis_object/storage/adapter.rb +46 -0
- data/lib/redis_object/storage/aws.rb +71 -0
- data/lib/redis_object/storage/mysql.rb +47 -0
- data/lib/redis_object/storage/redis.rb +119 -0
- data/lib/redis_object/timestamps.rb +74 -0
- data/lib/redis_object/tpl.rb +17 -0
- data/lib/redis_object/types.rb +276 -0
- data/lib/redis_object/validation.rb +89 -0
- data/lib/redis_object/version.rb +5 -0
- data/redis_object.gemspec +26 -0
- data/spec/adapter_spec.rb +43 -0
- data/spec/base_spec.rb +90 -0
- data/spec/benchmark_spec.rb +46 -0
- data/spec/collections_spec.rb +144 -0
- data/spec/defaults_spec.rb +56 -0
- data/spec/filters_spec.rb +29 -0
- data/spec/indices_spec.rb +45 -0
- data/spec/rename_class_spec.rb +96 -0
- data/spec/spec_helper.rb +38 -0
- data/spec/timestamp_spec.rb +28 -0
- data/spec/trigger_spec.rb +51 -0
- data/spec/types_spec.rb +103 -0
- data/spec/view_caching_spec.rb +130 -0
- data/spec/views_spec.rb +72 -0
- metadata +172 -0
@@ -0,0 +1,47 @@
|
|
1
|
+
module Seabright
|
2
|
+
module Storage
|
3
|
+
class MySQL
|
4
|
+
|
5
|
+
def set
|
6
|
+
|
7
|
+
end
|
8
|
+
|
9
|
+
def sadd
|
10
|
+
|
11
|
+
end
|
12
|
+
|
13
|
+
def del
|
14
|
+
|
15
|
+
end
|
16
|
+
|
17
|
+
def srem
|
18
|
+
|
19
|
+
end
|
20
|
+
|
21
|
+
def smembers
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
def exists
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
def hget
|
30
|
+
|
31
|
+
end
|
32
|
+
|
33
|
+
def hset
|
34
|
+
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def connection(num=0)
|
40
|
+
require 'mysql'
|
41
|
+
@connections ||= []
|
42
|
+
@connections[num] ||= MySQL.new
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
module Seabright
|
2
|
+
module Storage
|
3
|
+
class Redis < Adapter
|
4
|
+
|
5
|
+
def method_missing(sym, *args, &block)
|
6
|
+
return super unless connection.respond_to?(sym)
|
7
|
+
puts "[Storage::Redis] #{sym}(#{args.inspect.gsub(/\[|\]/m,'')})" if Debug.verbose?
|
8
|
+
begin
|
9
|
+
connection.send(sym,*args, &block)
|
10
|
+
rescue ::Redis::InheritedError => err
|
11
|
+
puts "Rescued: #{err.inspect}" if DEBUG
|
12
|
+
reset
|
13
|
+
connection.send(sym,*args, &block)
|
14
|
+
rescue ::Redis::TimeoutError => err
|
15
|
+
puts "Rescued connection timeout: #{err.inspect}" if DEBUG
|
16
|
+
reset
|
17
|
+
connection.send(sym,*args, &block)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def new_connection
|
22
|
+
require 'redis'
|
23
|
+
puts "Connecting to Redis with: #{config_opts(:path, :db, :password, :host, :port, :timeout, :tcp_keepalive).inspect}" if DEBUG
|
24
|
+
::Redis.new(config_opts(:path, :db, :password, :host, :port, :timeout, :tcp_keepalive))
|
25
|
+
end
|
26
|
+
|
27
|
+
DUMP_SEPARATOR = "---:::RedisObject::DUMP_SEPARATOR:::---"
|
28
|
+
REC_SEPARATOR = "---:::RedisObject::REC_SEPARATOR:::---"
|
29
|
+
|
30
|
+
def dump_to_file(file)
|
31
|
+
File.open(file,'wb') do |f|
|
32
|
+
keys = connection.send(:keys,"*")
|
33
|
+
f.write keys.map {|k|
|
34
|
+
v = connection.dump(k)
|
35
|
+
v.force_encoding(Encoding::BINARY)
|
36
|
+
[k,v].join(DUMP_SEPARATOR)
|
37
|
+
}.join(REC_SEPARATOR)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def restore_from_file(file)
|
42
|
+
str = File.read(file)
|
43
|
+
str.force_encoding(Encoding::BINARY)
|
44
|
+
str.split(REC_SEPARATOR).each do |line|
|
45
|
+
line.force_encoding(Encoding::BINARY)
|
46
|
+
key, val = line.split(DUMP_SEPARATOR)
|
47
|
+
connection.multi do
|
48
|
+
connection.del key
|
49
|
+
connection.restore key, 0, val
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def rename_class old_name, new_name
|
55
|
+
old_name = old_name.to_s#.split('::').last
|
56
|
+
new_name = new_name.to_s#.split('::').last
|
57
|
+
old_collection_name = old_name.split('::').last.underscore.pluralize
|
58
|
+
new_collection_name = new_name.split('::').last.underscore.pluralize
|
59
|
+
|
60
|
+
# references to type in collection data
|
61
|
+
keys("#{old_name}:*:backreferences").each do |backref_key|
|
62
|
+
smembers(backref_key).each do |hashref|
|
63
|
+
# there are two referenes we need to fix: individual references to items
|
64
|
+
# and lists of collection names.
|
65
|
+
#
|
66
|
+
# this updates the item references in collections
|
67
|
+
backref = hashref.sub(/_h$/,'');
|
68
|
+
old_collection = "#{backref}:COLLECTION:#{old_collection_name}"
|
69
|
+
new_collection = "#{backref}:COLLECTION:#{new_collection_name}"
|
70
|
+
zrange(old_collection, 0, 99999, withscores:true).each do |key, score|
|
71
|
+
zadd(new_collection, score, key.sub(/^#{old_name}/, new_name))
|
72
|
+
end
|
73
|
+
del(old_collection)
|
74
|
+
|
75
|
+
# this updates the lists of collection names
|
76
|
+
collection_names = "#{hashref}:collections"
|
77
|
+
smembers(collection_names).each do |collection_name|
|
78
|
+
if collection_name == old_collection_name
|
79
|
+
sadd(collection_names, new_collection_name)
|
80
|
+
srem(collection_names, old_collection_name)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
rename(backref_key, backref_key.sub(/^#{old_name}/, new_name))
|
85
|
+
end
|
86
|
+
|
87
|
+
# type-wide id index
|
88
|
+
smembers(old_name.pluralize).each do |key|
|
89
|
+
sadd(new_name.pluralize, key.sub(/^#{old_name}/, new_name))
|
90
|
+
old_class = hget("#{key}_h", :class)
|
91
|
+
old_key = hget("#{key}_h", :key)
|
92
|
+
hset("#{key}_h", :class, old_class.sub(/#{old_name}$/, new_name))
|
93
|
+
hset("#{key}_h", :key, old_key.sub(/^#{old_name}/, new_name))
|
94
|
+
hset("#{key}_h", "#{new_name.downcase}_id", key.sub(/^#{old_name}:/,''))
|
95
|
+
hdel("#{key}_h", "#{old_name.downcase}_id")
|
96
|
+
end
|
97
|
+
del(old_name.pluralize)
|
98
|
+
|
99
|
+
# column indexes
|
100
|
+
keys("#{old_name.pluralize}::*").each do |old_index|
|
101
|
+
new_index = old_index.sub(/^#{old_name.pluralize}/, new_name.pluralize)
|
102
|
+
zrange(old_index, 0, 99999, withscores:true).each do |key, score|
|
103
|
+
zadd(new_index, score, key.sub(/^#{old_name}/, new_name))
|
104
|
+
end
|
105
|
+
del(old_index)
|
106
|
+
end
|
107
|
+
|
108
|
+
# top-level keys
|
109
|
+
keys("#{old_name}:*").each do |key|
|
110
|
+
rename(key, key.sub(/^#{old_name}/, new_name))
|
111
|
+
end
|
112
|
+
keys("#{old_name.pluralize}:*").each do |key|
|
113
|
+
rename(key, key.sub(/^#{old_name.pluralize}/, new_name.pluralize))
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module Seabright
|
2
|
+
module Timestamps
|
3
|
+
|
4
|
+
def update_timestamps
|
5
|
+
# return unless self.class.time_matters?
|
6
|
+
set(:created_at, Time.now) if !is_set?(:created_at)
|
7
|
+
set(:updated_at, Time.now)
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
|
12
|
+
def intercept_sets_for_timestamps!
|
13
|
+
return if @intercepted_sets_for_timestamps
|
14
|
+
self.class_eval do
|
15
|
+
alias_method :untimestamped_set, :set unless method_defined?(:untimestamped_set)
|
16
|
+
def set(k,v)
|
17
|
+
ret = untimestamped_set(k,v)
|
18
|
+
set(:updated_at, Time.now) unless k.to_sym == :updated_at
|
19
|
+
ret
|
20
|
+
end
|
21
|
+
alias_method :untimestamped_mset, :mset unless method_defined?(:untimestamped_mset)
|
22
|
+
def mset(dat)
|
23
|
+
ret = untimestamped_mset(dat)
|
24
|
+
set(:updated_at, Time.now)
|
25
|
+
ret
|
26
|
+
end
|
27
|
+
alias_method :untimestamped_setnx, :setnx unless method_defined?(:untimestamped_setnx)
|
28
|
+
def setnx(k,v)
|
29
|
+
ret = untimestamped_setnx(k,v)
|
30
|
+
set(:updated_at, Time.now) unless k.to_sym == :updated_at
|
31
|
+
ret
|
32
|
+
end
|
33
|
+
alias_method :untimestamped_save, :save unless method_defined?(:untimestamped_save)
|
34
|
+
def save
|
35
|
+
ret = untimestamped_save()
|
36
|
+
update_timestamps
|
37
|
+
ret
|
38
|
+
end
|
39
|
+
end
|
40
|
+
@intercepted_sets_for_timestamps = true
|
41
|
+
end
|
42
|
+
|
43
|
+
# def time_matters?
|
44
|
+
# @time_irrelevant != true
|
45
|
+
# end
|
46
|
+
#
|
47
|
+
# def time_matters_not!
|
48
|
+
# @time_irrelevant = true
|
49
|
+
# sort_indices.delete(:created_at)
|
50
|
+
# sort_indices.delete(:updated_at)
|
51
|
+
# end
|
52
|
+
#
|
53
|
+
def recently_created(num=5)
|
54
|
+
self.indexed(:created_at,num,true)
|
55
|
+
end
|
56
|
+
|
57
|
+
def recently_updated(num=5)
|
58
|
+
self.indexed(:updated_at,num,true)
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
|
63
|
+
def self.included(base)
|
64
|
+
# @time_irrelevant = false
|
65
|
+
base.send(:sort_by,:created_at)
|
66
|
+
base.send(:sort_by,:updated_at)
|
67
|
+
base.send(:register_format,:created_at, :date)
|
68
|
+
base.send(:register_format,:updated_at, :date)
|
69
|
+
base.extend(ClassMethods)
|
70
|
+
base.intercept_sets_for_timestamps!
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,276 @@
|
|
1
|
+
module Seabright
|
2
|
+
module Types
|
3
|
+
|
4
|
+
def enforce_format(k,v)
|
5
|
+
if v && fmt = self.class.field_formats[k.to_sym]
|
6
|
+
send(fmt,v)
|
7
|
+
else
|
8
|
+
v
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def score_format(k,v)
|
13
|
+
if v && fmt = self.class.score_formats[k.to_sym]
|
14
|
+
send(fmt,v)
|
15
|
+
else
|
16
|
+
0
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def save_format(k,v)
|
21
|
+
v && (fmt = self.class.save_formats[k.to_s.gsub(/\=$/,'').to_sym]) ? send(fmt,v) : v
|
22
|
+
end
|
23
|
+
|
24
|
+
def format_date(val)
|
25
|
+
begin
|
26
|
+
val.is_a?(DateTime) || val.is_a?(Date) || val.is_a?(Time) ? val : ( val.is_a?(String) ? DateTime.parse(val) : nil )
|
27
|
+
rescue StandardError => e
|
28
|
+
puts "Could not parse value as date using Date.parse. Returning nil instead. Value: #{val.inspect}\nError: #{e.inspect}" if DEBUG
|
29
|
+
nil
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def score_date(val)
|
34
|
+
val.to_time.to_i
|
35
|
+
end
|
36
|
+
|
37
|
+
def format_array(val)
|
38
|
+
Yajl::Parser.new(:symbolize_keys => true).parse(val)
|
39
|
+
end
|
40
|
+
|
41
|
+
def save_array(val)
|
42
|
+
Yajl::Encoder.encode(val)
|
43
|
+
end
|
44
|
+
|
45
|
+
def format_number(val)
|
46
|
+
val.to_i
|
47
|
+
end
|
48
|
+
|
49
|
+
def score_number(val)
|
50
|
+
Float(val)
|
51
|
+
end
|
52
|
+
|
53
|
+
def format_float(val)
|
54
|
+
Float(val)
|
55
|
+
end
|
56
|
+
alias_method :score_float, :format_float
|
57
|
+
|
58
|
+
def format_json(val)
|
59
|
+
Yajl::Parser.new(:symbolize_keys => true).parse(val)
|
60
|
+
end
|
61
|
+
|
62
|
+
def save_json(val)
|
63
|
+
Yajl::Encoder.encode(val)
|
64
|
+
end
|
65
|
+
|
66
|
+
def format_boolean(val)
|
67
|
+
val=="true"
|
68
|
+
end
|
69
|
+
|
70
|
+
def save_boolean(val)
|
71
|
+
val === true ? "true" : "false"
|
72
|
+
end
|
73
|
+
|
74
|
+
def score_boolean(val)
|
75
|
+
val ? 1 : 0
|
76
|
+
end
|
77
|
+
|
78
|
+
module ClassMethods
|
79
|
+
|
80
|
+
def date(k)
|
81
|
+
set_field_format(k, :format_date)
|
82
|
+
set_score_format(k, :score_date)
|
83
|
+
end
|
84
|
+
|
85
|
+
def number(k)
|
86
|
+
set_field_format(k, :format_number)
|
87
|
+
set_score_format(k, :score_number)
|
88
|
+
end
|
89
|
+
alias_method :int, :number
|
90
|
+
|
91
|
+
def float(k)
|
92
|
+
set_field_format(k, :format_float)
|
93
|
+
set_score_format(k, :score_float)
|
94
|
+
end
|
95
|
+
|
96
|
+
def bool(k)
|
97
|
+
set_field_format(k, :format_boolean)
|
98
|
+
set_score_format(k, :score_boolean)
|
99
|
+
set_save_format(k, :save_boolean)
|
100
|
+
end
|
101
|
+
alias_method :boolean, :bool
|
102
|
+
|
103
|
+
def array(k)
|
104
|
+
set_field_format(k, :format_array)
|
105
|
+
set_save_format(k, :save_array)
|
106
|
+
end
|
107
|
+
|
108
|
+
def json(k)
|
109
|
+
set_field_format(k, :format_json)
|
110
|
+
set_save_format(k, :save_json)
|
111
|
+
end
|
112
|
+
|
113
|
+
def field_formats
|
114
|
+
@field_formats_hash ||= (defined?(superclass.field_formats) ? superclass.field_formats.clone : {})
|
115
|
+
end
|
116
|
+
|
117
|
+
def score_formats
|
118
|
+
@score_formats_hash ||= (defined?(superclass.score_formats) ? superclass.score_formats.clone : {})
|
119
|
+
end
|
120
|
+
|
121
|
+
def save_formats
|
122
|
+
@save_formats_hash ||= (defined?(superclass.save_formats) ? superclass.save_formats.clone : {})
|
123
|
+
end
|
124
|
+
|
125
|
+
def set_field_format(k, v)
|
126
|
+
field_formats_set_locally.add(k)
|
127
|
+
field_formats[k] = v
|
128
|
+
update_child_class_field_formats(k, v)
|
129
|
+
intercept_for_typing!
|
130
|
+
end
|
131
|
+
|
132
|
+
def field_formats_set_locally
|
133
|
+
@field_formats_set_locally_set ||= Set.new
|
134
|
+
end
|
135
|
+
|
136
|
+
def inherit_field_format(k, v)
|
137
|
+
unless fields_formats_set_locally.include? k
|
138
|
+
field_formats[k] = v
|
139
|
+
update_child_class_field_formats(k, v)
|
140
|
+
end
|
141
|
+
intercept_for_typing!
|
142
|
+
end
|
143
|
+
|
144
|
+
def update_child_class_field_formats(k, v)
|
145
|
+
child_classes.each do |child_class|
|
146
|
+
child_class.inherit_field_format(k, v)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def set_score_format(k, v)
|
151
|
+
score_formats_set_locally.add(k)
|
152
|
+
score_formats[k] = v
|
153
|
+
update_child_class_score_formats(k, v)
|
154
|
+
intercept_for_typing!
|
155
|
+
end
|
156
|
+
|
157
|
+
def score_formats_set_locally
|
158
|
+
@score_formats_set_locally_set ||= Set.new
|
159
|
+
end
|
160
|
+
|
161
|
+
def inherit_score_format(k, v)
|
162
|
+
unless scores_formats_set_locally.include? k
|
163
|
+
score_formats[k] = v
|
164
|
+
update_child_class_score_formats(k, v)
|
165
|
+
end
|
166
|
+
intercept_for_typing!
|
167
|
+
end
|
168
|
+
|
169
|
+
def update_child_class_score_formats(k, v)
|
170
|
+
child_classes.each do |child_class|
|
171
|
+
child_class.inherit_score_format(k, v)
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def set_save_format(k, v)
|
176
|
+
save_formats_set_locally.add(k)
|
177
|
+
save_formats[k] = v
|
178
|
+
update_child_class_save_formats(k, v)
|
179
|
+
intercept_for_typing!
|
180
|
+
end
|
181
|
+
|
182
|
+
def save_formats_set_locally
|
183
|
+
@save_formats_set_locally_set ||= Set.new
|
184
|
+
end
|
185
|
+
|
186
|
+
def inherit_save_format(k, v)
|
187
|
+
unless save_formats_set_locally.include? k
|
188
|
+
save_formats[k] = v
|
189
|
+
update_child_class_save_formats(k, v)
|
190
|
+
end
|
191
|
+
intercept_for_typing!
|
192
|
+
end
|
193
|
+
|
194
|
+
def update_child_class_save_formats(k, v)
|
195
|
+
child_classes.each do |child_class|
|
196
|
+
child_class.inherit_save_format(k, v)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def register_format(k,fmt)
|
201
|
+
send(fmt, k)
|
202
|
+
end
|
203
|
+
|
204
|
+
def describe
|
205
|
+
all_keys.inject({}) do |acc,(k,v)|
|
206
|
+
if field_formats[k.to_sym]
|
207
|
+
acc[k.to_sym] ||= [field_formats[k.to_sym].to_s.gsub(/^format_/,'').to_sym, 0]
|
208
|
+
else
|
209
|
+
acc[k.to_sym] ||= [:string, 0]
|
210
|
+
end
|
211
|
+
acc[k.to_sym][1] += 1
|
212
|
+
acc
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
def dump_schema(file)
|
217
|
+
child_classes_set.sort {|a,b| a.name <=> b.name}.each do |child|
|
218
|
+
file.puts "# #{child.name}"
|
219
|
+
# sort fields by number of instances found
|
220
|
+
child.describe.sort {|a,b| b[1][1] <=> a[1][1]}.each do |field,(type, count)|
|
221
|
+
file.puts "#{field}: #{type} (#{count})"
|
222
|
+
end
|
223
|
+
file.puts
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def intercept_for_typing!
|
228
|
+
return if @intercepted_for_typing
|
229
|
+
self.class_eval do
|
230
|
+
|
231
|
+
alias_method :untyped_get, :get unless method_defined?(:untyped_get)
|
232
|
+
def get(k)
|
233
|
+
enforce_format(k,untyped_get(k))
|
234
|
+
end
|
235
|
+
|
236
|
+
alias_method :untyped_mset, :mset unless method_defined?(:untyped_mset)
|
237
|
+
def mset(dat)
|
238
|
+
dat.merge!(dat) {|k,v1,v2| save_format(k,v1) }
|
239
|
+
untyped_mset(dat)
|
240
|
+
end
|
241
|
+
|
242
|
+
alias_method :untyped_set, :set unless method_defined?(:untyped_set)
|
243
|
+
def set(k,v)
|
244
|
+
untyped_set(k,save_format(k,v))
|
245
|
+
end
|
246
|
+
|
247
|
+
alias_method :untyped_setnx, :setnx unless method_defined?(:untyped_setnx)
|
248
|
+
def setnx(k,v)
|
249
|
+
untyped_setnx(k,save_format(k,v))
|
250
|
+
end
|
251
|
+
|
252
|
+
end
|
253
|
+
@intercepted_for_typing = true
|
254
|
+
end
|
255
|
+
|
256
|
+
private
|
257
|
+
|
258
|
+
def all_keys(limit=100)
|
259
|
+
steps = 0
|
260
|
+
all.inject([]) do |acc,obj|
|
261
|
+
store.hkeys(obj.hkey).each do |k|
|
262
|
+
acc << k.to_sym
|
263
|
+
end
|
264
|
+
steps += 1
|
265
|
+
return acc if steps >= limit
|
266
|
+
acc
|
267
|
+
end
|
268
|
+
end
|
269
|
+
end
|
270
|
+
|
271
|
+
def self.included(base)
|
272
|
+
base.extend(ClassMethods)
|
273
|
+
end
|
274
|
+
|
275
|
+
end
|
276
|
+
end
|