modis 1.3.0 → 1.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +7 -0
- data/.ruby-version +1 -1
- data/.travis.yml +10 -5
- data/Gemfile +8 -4
- data/Gemfile.lock +42 -48
- data/README.md +2 -2
- data/benchmark/bench.rb +65 -0
- data/benchmark/find.rb +62 -0
- data/benchmark/persistence.rb +82 -0
- data/benchmark/redis/connection/fakedis.rb +90 -0
- data/lib/modis.rb +2 -1
- data/lib/modis/attribute.rb +47 -27
- data/lib/modis/configuration.rb +5 -3
- data/lib/modis/finder.rb +31 -19
- data/lib/modis/index.rb +19 -27
- data/lib/modis/model.rb +2 -3
- data/lib/modis/persistence.rb +87 -56
- data/lib/modis/version.rb +1 -1
- data/lib/tasks/quality.rake +19 -12
- data/modis.gemspec +12 -4
- data/spec/attribute_spec.rb +5 -5
- data/spec/finder_spec.rb +27 -1
- data/spec/persistence_spec.rb +57 -4
- data/spec/spec_helper.rb +8 -6
- data/spec/support/simplecov_helper.rb +9 -5
- metadata +35 -4
- data/.coveralls.yml +0 -1
data/lib/modis.rb
CHANGED
@@ -3,6 +3,7 @@ require 'connection_pool'
|
|
3
3
|
require 'active_model'
|
4
4
|
require 'active_support/all'
|
5
5
|
require 'yaml'
|
6
|
+
require 'msgpack'
|
6
7
|
|
7
8
|
require 'modis/version'
|
8
9
|
require 'modis/configuration'
|
@@ -22,7 +23,7 @@ module Modis
|
|
22
23
|
:connection_pool_timeout
|
23
24
|
end
|
24
25
|
|
25
|
-
self.redis_options = {}
|
26
|
+
self.redis_options = { driver: :hiredis }
|
26
27
|
self.connection_pool_size = 5
|
27
28
|
self.connection_pool_timeout = 5
|
28
29
|
|
data/lib/modis/attribute.rb
CHANGED
@@ -1,6 +1,12 @@
|
|
1
1
|
module Modis
|
2
2
|
module Attribute
|
3
|
-
TYPES =
|
3
|
+
TYPES = { string: [String],
|
4
|
+
integer: [Fixnum],
|
5
|
+
float: [Float],
|
6
|
+
timestamp: [Time],
|
7
|
+
hash: [Hash],
|
8
|
+
array: [Array],
|
9
|
+
boolean: [TrueClass, FalseClass] }.freeze
|
4
10
|
|
5
11
|
def self.included(base)
|
6
12
|
base.extend ClassMethods
|
@@ -10,46 +16,64 @@ module Modis
|
|
10
16
|
end
|
11
17
|
|
12
18
|
module ClassMethods
|
13
|
-
def bootstrap_attributes
|
19
|
+
def bootstrap_attributes(parent = nil)
|
20
|
+
attr_reader :attributes
|
21
|
+
|
14
22
|
class << self
|
15
|
-
attr_accessor :attributes
|
23
|
+
attr_accessor :attributes, :attributes_with_defaults
|
16
24
|
end
|
17
25
|
|
18
|
-
self.attributes = {}
|
26
|
+
self.attributes = parent ? parent.attributes.dup : {}
|
27
|
+
self.attributes_with_defaults = parent ? parent.attributes_with_defaults.dup : {}
|
19
28
|
|
20
|
-
attribute :id, :integer
|
29
|
+
attribute :id, :integer unless parent
|
21
30
|
end
|
22
31
|
|
23
|
-
def attribute(name,
|
32
|
+
def attribute(name, type, options = {})
|
24
33
|
name = name.to_s
|
25
|
-
types = Array[*types]
|
26
34
|
raise AttributeError, "Attribute with name '#{name}' has already been specified." if attributes.key?(name)
|
27
|
-
types.each { |type| raise UnsupportedAttributeType, type unless TYPES.include?(type) }
|
28
35
|
|
29
|
-
|
30
|
-
|
31
|
-
|
36
|
+
type_classes = Array(type).map do |t|
|
37
|
+
raise UnsupportedAttributeType, t unless TYPES.key?(t)
|
38
|
+
TYPES[t]
|
39
|
+
end.flatten
|
40
|
+
|
41
|
+
attributes[name] = options.update(type: type)
|
42
|
+
attributes_with_defaults[name] = options[:default] if options[:default]
|
43
|
+
define_attribute_methods([name])
|
44
|
+
|
45
|
+
value_coercion = type == :timestamp ? 'value = Time.new(*value) if value && value.is_a?(Array) && value.count == 7' : nil
|
46
|
+
predicate = type_classes.map { |cls| "value.is_a?(#{cls.name})" }.join(' || ')
|
47
|
+
|
48
|
+
type_check = <<-RUBY
|
49
|
+
if value && !(#{predicate})
|
50
|
+
raise Modis::AttributeCoercionError, "Received value of type '\#{value.class}', expected '#{type_classes.join("', '")}' for attribute '#{name}'."
|
51
|
+
end
|
52
|
+
RUBY
|
53
|
+
|
54
|
+
class_eval <<-RUBY, __FILE__, __LINE__
|
32
55
|
def #{name}
|
33
56
|
attributes['#{name}']
|
34
57
|
end
|
35
58
|
|
36
59
|
def #{name}=(value)
|
37
|
-
|
38
|
-
|
39
|
-
|
60
|
+
#{value_coercion}
|
61
|
+
|
62
|
+
# ActiveSupport's Time#<=> does not perform well when comparing with NilClass.
|
63
|
+
if (value.nil? ^ attributes['#{name}'].nil?) || (value != attributes['#{name}'])
|
64
|
+
#{type_check}
|
65
|
+
#{name}_will_change!
|
66
|
+
attributes['#{name}'] = value
|
67
|
+
end
|
40
68
|
end
|
41
|
-
|
69
|
+
RUBY
|
42
70
|
end
|
43
71
|
end
|
44
72
|
|
45
|
-
def attributes
|
46
|
-
@attributes ||= Hash[self.class.attributes.keys.zip]
|
47
|
-
end
|
48
|
-
|
49
73
|
def assign_attributes(hash)
|
50
74
|
hash.each do |k, v|
|
51
75
|
setter = "#{k}="
|
52
|
-
send(setter, v) if
|
76
|
+
send(setter, v) if self.class.attributes.key?(k.to_s)
|
53
77
|
end
|
54
78
|
end
|
55
79
|
|
@@ -65,19 +89,15 @@ module Modis
|
|
65
89
|
|
66
90
|
def set_sti_type
|
67
91
|
return unless self.class.sti_child?
|
68
|
-
|
92
|
+
write_attribute(:type, self.class.name)
|
69
93
|
end
|
70
94
|
|
71
95
|
def reset_changes
|
72
|
-
@changed_attributes
|
96
|
+
@changed_attributes = nil
|
73
97
|
end
|
74
98
|
|
75
99
|
def apply_defaults
|
76
|
-
|
77
|
-
self.class.attributes.each do |attribute, options|
|
78
|
-
defaults[attribute] = options[:default] if options[:default]
|
79
|
-
end
|
80
|
-
assign_attributes(defaults)
|
100
|
+
@attributes = Hash[self.class.attributes_with_defaults]
|
81
101
|
end
|
82
102
|
end
|
83
103
|
end
|
data/lib/modis/configuration.rb
CHANGED
@@ -3,10 +3,12 @@ module Modis
|
|
3
3
|
yield config
|
4
4
|
end
|
5
5
|
|
6
|
-
|
7
|
-
@config ||= Configuration.new
|
6
|
+
class Configuration < Struct.new(:namespace)
|
8
7
|
end
|
9
8
|
|
10
|
-
class
|
9
|
+
class << self
|
10
|
+
attr_reader :config
|
11
11
|
end
|
12
|
+
|
13
|
+
@config = Configuration.new
|
12
14
|
end
|
data/lib/modis/finder.rb
CHANGED
@@ -5,33 +5,26 @@ module Modis
|
|
5
5
|
end
|
6
6
|
|
7
7
|
module ClassMethods
|
8
|
-
def find(
|
9
|
-
|
10
|
-
|
11
|
-
model_for(attributes)
|
12
|
-
end
|
8
|
+
def find(*ids)
|
9
|
+
models = find_all(ids)
|
10
|
+
ids.count == 1 ? models.first : models
|
13
11
|
end
|
14
12
|
|
15
13
|
def all
|
16
|
-
records =
|
17
|
-
|
18
|
-
Modis.with_connection do |redis|
|
14
|
+
records = Modis.with_connection do |redis|
|
19
15
|
ids = redis.smembers(key_for(:all))
|
20
|
-
|
16
|
+
redis.pipelined do
|
21
17
|
ids.map { |id| record_for(redis, id) }
|
22
18
|
end
|
23
19
|
end
|
24
20
|
|
25
|
-
records
|
26
|
-
attributes = record_to_attributes(record)
|
27
|
-
model_for(attributes)
|
28
|
-
end
|
21
|
+
records_to_models(records)
|
29
22
|
end
|
30
23
|
|
31
24
|
def attributes_for(redis, id)
|
32
25
|
raise RecordNotFound, "Couldn't find #{name} without an ID" if id.nil?
|
33
26
|
|
34
|
-
attributes =
|
27
|
+
attributes = deserialize(record_for(redis, id))
|
35
28
|
|
36
29
|
unless attributes['id'].present?
|
37
30
|
raise RecordNotFound, "Couldn't find #{name} with id=#{id}"
|
@@ -40,15 +33,34 @@ module Modis
|
|
40
33
|
attributes
|
41
34
|
end
|
42
35
|
|
36
|
+
def find_all(ids)
|
37
|
+
raise RecordNotFound, "Couldn't find #{name} without an ID" if ids.empty?
|
38
|
+
|
39
|
+
records = Modis.with_connection do |redis|
|
40
|
+
blk = proc { |id| record_for(redis, id) }
|
41
|
+
ids.count == 1 ? ids.map(&blk) : redis.pipelined { ids.map(&blk) }
|
42
|
+
end
|
43
|
+
|
44
|
+
models = records_to_models(records)
|
45
|
+
|
46
|
+
if models.count < ids.count
|
47
|
+
missing = ids - models.map(&:id)
|
48
|
+
raise RecordNotFound, "Couldn't find #{name} with id=#{missing.first}"
|
49
|
+
end
|
50
|
+
|
51
|
+
models
|
52
|
+
end
|
53
|
+
|
43
54
|
private
|
44
55
|
|
45
|
-
def
|
46
|
-
|
56
|
+
def records_to_models(records)
|
57
|
+
records.map do |record|
|
58
|
+
model_for(deserialize(record)) unless record.blank?
|
59
|
+
end.compact
|
47
60
|
end
|
48
61
|
|
49
|
-
def
|
50
|
-
|
51
|
-
record
|
62
|
+
def model_for(attributes)
|
63
|
+
model_class(attributes).new(attributes, new_record: false)
|
52
64
|
end
|
53
65
|
|
54
66
|
def record_for(redis, id)
|
data/lib/modis/index.rb
CHANGED
@@ -4,19 +4,16 @@ module Modis
|
|
4
4
|
base.extend ClassMethods
|
5
5
|
base.instance_eval do
|
6
6
|
bootstrap_indexes
|
7
|
-
after__internal_create :add_to_index
|
8
|
-
after__internal_update :update_index
|
9
|
-
before__internal_destroy :remove_from_index
|
10
7
|
end
|
11
8
|
end
|
12
9
|
|
13
10
|
module ClassMethods
|
14
|
-
def bootstrap_indexes
|
11
|
+
def bootstrap_indexes(parent = nil)
|
15
12
|
class << self
|
16
13
|
attr_accessor :indexed_attributes
|
17
14
|
end
|
18
15
|
|
19
|
-
self.indexed_attributes = []
|
16
|
+
self.indexed_attributes = parent ? parent.indexed_attributes.dup : []
|
20
17
|
end
|
21
18
|
|
22
19
|
def index(attribute)
|
@@ -28,8 +25,9 @@ module Modis
|
|
28
25
|
def where(query)
|
29
26
|
raise IndexError, 'Queries using multiple indexes is not currently supported.' if query.keys.size > 1
|
30
27
|
attribute, value = query.first
|
31
|
-
|
32
|
-
|
28
|
+
ids = index_for(attribute, value)
|
29
|
+
return [] if ids.empty?
|
30
|
+
find_all(ids)
|
33
31
|
end
|
34
32
|
|
35
33
|
def index_for(attribute, value)
|
@@ -54,38 +52,32 @@ module Modis
|
|
54
52
|
self.class.index_key(attribute, value)
|
55
53
|
end
|
56
54
|
|
57
|
-
def
|
55
|
+
def add_to_indexes(redis)
|
58
56
|
return if indexed_attributes.empty?
|
59
57
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
redis.sadd(key, id)
|
64
|
-
end
|
58
|
+
indexed_attributes.each do |attribute|
|
59
|
+
key = index_key(attribute, read_attribute(attribute))
|
60
|
+
redis.sadd(key, id)
|
65
61
|
end
|
66
62
|
end
|
67
63
|
|
68
|
-
def
|
64
|
+
def remove_from_indexes(redis)
|
69
65
|
return if indexed_attributes.empty?
|
70
66
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
redis.srem(key, id)
|
75
|
-
end
|
67
|
+
indexed_attributes.each do |attribute|
|
68
|
+
key = index_key(attribute, read_attribute(attribute))
|
69
|
+
redis.srem(key, id)
|
76
70
|
end
|
77
71
|
end
|
78
72
|
|
79
|
-
def
|
73
|
+
def update_indexes(redis)
|
80
74
|
return if indexed_attributes.empty?
|
81
75
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
redis.smove(old_key, new_key, id)
|
88
|
-
end
|
76
|
+
(changes.keys & indexed_attributes).each do |attribute|
|
77
|
+
old_value, new_value = changes[attribute]
|
78
|
+
old_key = index_key(attribute, old_value)
|
79
|
+
new_key = index_key(attribute, new_value)
|
80
|
+
redis.smove(old_key, new_key, id)
|
89
81
|
end
|
90
82
|
end
|
91
83
|
end
|
data/lib/modis/model.rb
CHANGED
@@ -10,7 +10,6 @@ module Modis
|
|
10
10
|
extend ActiveModel::Callbacks
|
11
11
|
|
12
12
|
define_model_callbacks :save, :create, :update, :destroy
|
13
|
-
define_model_callbacks :_internal_create, :_internal_update, :_internal_destroy
|
14
13
|
|
15
14
|
include Modis::Errors
|
16
15
|
include Modis::Transaction
|
@@ -31,9 +30,9 @@ module Modis
|
|
31
30
|
end
|
32
31
|
|
33
32
|
def initialize(record = nil, options = {})
|
34
|
-
set_sti_type
|
35
33
|
apply_defaults
|
36
|
-
|
34
|
+
set_sti_type
|
35
|
+
assign_attributes(record) if record
|
37
36
|
reset_changes
|
38
37
|
|
39
38
|
return unless options.key?(:new_record)
|
data/lib/modis/persistence.rb
CHANGED
@@ -3,8 +3,10 @@ module Modis
|
|
3
3
|
def self.included(base)
|
4
4
|
base.extend ClassMethods
|
5
5
|
base.instance_eval do
|
6
|
-
|
7
|
-
|
6
|
+
class << self
|
7
|
+
attr_reader :sti_child
|
8
|
+
alias_method :sti_child?, :sti_child
|
9
|
+
end
|
8
10
|
end
|
9
11
|
end
|
10
12
|
|
@@ -19,31 +21,26 @@ module Modis
|
|
19
21
|
attribute :type, :string unless attributes.key?('type')
|
20
22
|
end
|
21
23
|
|
22
|
-
class << self
|
23
|
-
delegate :attributes, :indexed_attributes, to: :sti_parent
|
24
|
-
end
|
25
|
-
|
26
24
|
@sti_child = true
|
27
25
|
@sti_parent = parent
|
28
|
-
end
|
29
|
-
end
|
30
26
|
|
31
|
-
|
32
|
-
|
33
|
-
|
27
|
+
bootstrap_attributes(parent)
|
28
|
+
bootstrap_indexes(parent)
|
29
|
+
end
|
34
30
|
end
|
35
31
|
|
36
32
|
def namespace
|
37
33
|
return sti_parent.namespace if sti_child?
|
38
|
-
|
39
|
-
@namespace = name.split('::').map(&:underscore).join(':')
|
34
|
+
@namespace ||= name.split('::').map(&:underscore).join(':')
|
40
35
|
end
|
41
36
|
|
42
|
-
|
37
|
+
def namespace=(value)
|
38
|
+
@namespace = value
|
39
|
+
@absolute_namespace = nil
|
40
|
+
end
|
43
41
|
|
44
42
|
def absolute_namespace
|
45
|
-
|
46
|
-
@absolute_namespace = parts.compact.join(':')
|
43
|
+
@absolute_namespace ||= [Modis.config.namespace, namespace].compact.join(':')
|
47
44
|
end
|
48
45
|
|
49
46
|
def key_for(id)
|
@@ -62,8 +59,43 @@ module Modis
|
|
62
59
|
model
|
63
60
|
end
|
64
61
|
|
65
|
-
|
66
|
-
|
62
|
+
YAML_MARKER = '---'.freeze
|
63
|
+
def deserialize(record)
|
64
|
+
values = record.values
|
65
|
+
values = MessagePack.unpack(msgpack_array_header(values.size) + values.join)
|
66
|
+
keys = record.keys
|
67
|
+
values.each_with_index { |v, i| record[keys[i]] = v }
|
68
|
+
record
|
69
|
+
rescue MessagePack::MalformedFormatError
|
70
|
+
found_yaml = false
|
71
|
+
|
72
|
+
record.each do |k, v|
|
73
|
+
if v.start_with?(YAML_MARKER)
|
74
|
+
found_yaml = true
|
75
|
+
record[k] = YAML.load(v)
|
76
|
+
else
|
77
|
+
record[k] = MessagePack.unpack(v)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
if found_yaml
|
82
|
+
id = record['id']
|
83
|
+
STDERR.puts "#{self}(id: #{id}) contains attributes serialized as YAML. As of Modis 1.4.0, YAML is no longer used as the serialization format. To improve performance loading this record, you can force the record to new serialization format (MessagePack) with: #{self}.find(#{id}).save!(yaml_sucks: true)"
|
84
|
+
end
|
85
|
+
|
86
|
+
record
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def msgpack_array_header(n)
|
92
|
+
if n < 16
|
93
|
+
[0x90 | n].pack("C")
|
94
|
+
elsif n < 65536
|
95
|
+
[0xDC, n].pack("Cn")
|
96
|
+
else
|
97
|
+
[0xDD, n].pack("CN")
|
98
|
+
end.force_encoding(Encoding::UTF_8)
|
67
99
|
end
|
68
100
|
end
|
69
101
|
|
@@ -92,7 +124,9 @@ module Modis
|
|
92
124
|
def destroy
|
93
125
|
self.class.transaction do |redis|
|
94
126
|
run_callbacks :destroy do
|
95
|
-
|
127
|
+
redis.pipelined do
|
128
|
+
remove_from_indexes(redis)
|
129
|
+
redis.srem(self.class.key_for(:all), id)
|
96
130
|
redis.del(key)
|
97
131
|
end
|
98
132
|
end
|
@@ -122,37 +156,18 @@ module Modis
|
|
122
156
|
|
123
157
|
private
|
124
158
|
|
125
|
-
def coerce_for_persistence(
|
126
|
-
|
127
|
-
|
128
|
-
end
|
129
|
-
|
130
|
-
def ensure_type(attribute, value)
|
131
|
-
types = self.class.attributes[attribute.to_s][:types]
|
132
|
-
type_classes = types.map { |type| classes_for_type(type) }.flatten
|
133
|
-
|
134
|
-
return unless value && !type_classes.include?(value.class)
|
135
|
-
raise Modis::AttributeCoercionError, "Received value of type '#{value.class}', expected '#{type_classes.join('\', \'')}' for attribute '#{attribute}'."
|
136
|
-
end
|
137
|
-
|
138
|
-
def classes_for_type(type)
|
139
|
-
{ string: [String],
|
140
|
-
integer: [Fixnum],
|
141
|
-
float: [Float],
|
142
|
-
timestamp: [Time],
|
143
|
-
hash: [Hash],
|
144
|
-
array: [Array],
|
145
|
-
boolean: [TrueClass, FalseClass] }[type]
|
159
|
+
def coerce_for_persistence(value)
|
160
|
+
value = [value.year, value.month, value.day, value.hour, value.min, value.sec, value.strftime("%:z")] if value.is_a?(Time)
|
161
|
+
MessagePack.pack(value)
|
146
162
|
end
|
147
163
|
|
148
164
|
def create_or_update(args = {})
|
149
165
|
validate(args)
|
150
|
-
future = persist
|
166
|
+
future = persist(args[:yaml_sucks])
|
151
167
|
|
152
|
-
if future && future.value == 'OK'
|
168
|
+
if future && (future == :unchanged || future.value == 'OK')
|
153
169
|
reset_changes
|
154
170
|
@new_record = false
|
155
|
-
new_record? ? add_to_index : update_index
|
156
171
|
true
|
157
172
|
else
|
158
173
|
false
|
@@ -165,7 +180,7 @@ module Modis
|
|
165
180
|
raise Modis::RecordInvalid, errors.full_messages.join(', ')
|
166
181
|
end
|
167
182
|
|
168
|
-
def persist
|
183
|
+
def persist(persist_all)
|
169
184
|
future = nil
|
170
185
|
set_id if new_record?
|
171
186
|
callback = new_record? ? :create : :update
|
@@ -173,10 +188,16 @@ module Modis
|
|
173
188
|
self.class.transaction do |redis|
|
174
189
|
run_callbacks :save do
|
175
190
|
run_callbacks callback do
|
176
|
-
|
177
|
-
attrs =
|
178
|
-
|
179
|
-
|
191
|
+
redis.pipelined do
|
192
|
+
attrs = coerced_attributes(persist_all)
|
193
|
+
future = attrs.any? ? redis.hmset(self.class.key_for(id), attrs) : :unchanged
|
194
|
+
|
195
|
+
if new_record?
|
196
|
+
redis.sadd(self.class.key_for(:all), id)
|
197
|
+
add_to_indexes(redis)
|
198
|
+
else
|
199
|
+
update_indexes(redis)
|
200
|
+
end
|
180
201
|
end
|
181
202
|
end
|
182
203
|
end
|
@@ -185,18 +206,28 @@ module Modis
|
|
185
206
|
future
|
186
207
|
end
|
187
208
|
|
188
|
-
def
|
189
|
-
|
190
|
-
|
209
|
+
def coerced_attributes(persist_all) # rubocop:disable Metrics/AbcSize
|
210
|
+
attrs = []
|
211
|
+
|
212
|
+
if new_record? || persist_all
|
213
|
+
attributes.each do |k, v|
|
214
|
+
if (self.class.attributes[k][:default] || nil) != v
|
215
|
+
attrs << k << coerce_for_persistence(v)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
else
|
219
|
+
changed_attributes.each do |k, _|
|
220
|
+
attrs << k << coerce_for_persistence(attributes[k])
|
221
|
+
end
|
191
222
|
end
|
192
|
-
end
|
193
223
|
|
194
|
-
|
195
|
-
Modis.with_connection { |redis| redis.sadd(self.class.key_for(:all), id) }
|
224
|
+
attrs
|
196
225
|
end
|
197
226
|
|
198
|
-
def
|
199
|
-
Modis.with_connection
|
227
|
+
def set_id
|
228
|
+
Modis.with_connection do |redis|
|
229
|
+
self.id = redis.incr("#{self.class.absolute_namespace}_id_seq")
|
230
|
+
end
|
200
231
|
end
|
201
232
|
end
|
202
233
|
end
|