modis 1.3.0 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
 
@@ -1,6 +1,12 @@
1
1
  module Modis
2
2
  module Attribute
3
- TYPES = [:string, :integer, :float, :timestamp, :boolean, :array, :hash]
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, types, options = {})
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
- attributes[name] = options.update(types: types)
30
- define_attribute_methods [name]
31
- class_eval <<-EOS, __FILE__, __LINE__
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
- ensure_type('#{name}', value)
38
- #{name}_will_change! unless value == attributes['#{name}']
39
- attributes['#{name}'] = value
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
- EOS
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 respond_to?(setter)
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
- assign_attributes(type: self.class.name)
92
+ write_attribute(:type, self.class.name)
69
93
  end
70
94
 
71
95
  def reset_changes
72
- @changed_attributes.clear if @changed_attributes
96
+ @changed_attributes = nil
73
97
  end
74
98
 
75
99
  def apply_defaults
76
- defaults = {}
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
@@ -3,10 +3,12 @@ module Modis
3
3
  yield config
4
4
  end
5
5
 
6
- def self.config
7
- @config ||= Configuration.new
6
+ class Configuration < Struct.new(:namespace)
8
7
  end
9
8
 
10
- class Configuration < Struct.new(:namespace)
9
+ class << self
10
+ attr_reader :config
11
11
  end
12
+
13
+ @config = Configuration.new
12
14
  end
@@ -5,33 +5,26 @@ module Modis
5
5
  end
6
6
 
7
7
  module ClassMethods
8
- def find(id)
9
- Modis.with_connection do |redis|
10
- attributes = attributes_for(redis, id)
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
- records = redis.pipelined do
16
+ redis.pipelined do
21
17
  ids.map { |id| record_for(redis, id) }
22
18
  end
23
19
  end
24
20
 
25
- records.map do |record|
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 = record_to_attributes(record_for(redis, id))
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 model_for(attributes)
46
- model_class(attributes).new(attributes, new_record: false)
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 record_to_attributes(record)
50
- record.each { |k, v| record[k] = coerce_from_persistence(v) }
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)
@@ -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
- index = index_for(attribute, value)
32
- index.map { |id| find(id) }
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 add_to_index
55
+ def add_to_indexes(redis)
58
56
  return if indexed_attributes.empty?
59
57
 
60
- Modis.with_connection do |redis|
61
- indexed_attributes.each do |attribute|
62
- key = index_key(attribute, read_attribute(attribute))
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 remove_from_index
64
+ def remove_from_indexes(redis)
69
65
  return if indexed_attributes.empty?
70
66
 
71
- Modis.with_connection do |redis|
72
- indexed_attributes.each do |attribute|
73
- key = index_key(attribute, read_attribute(attribute))
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 update_index
73
+ def update_indexes(redis)
80
74
  return if indexed_attributes.empty?
81
75
 
82
- Modis.with_connection do |redis|
83
- (changes.keys & indexed_attributes).each do |attribute|
84
- old_value, new_value = changes[attribute]
85
- old_key = index_key(attribute, old_value)
86
- new_key = index_key(attribute, new_value)
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
@@ -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
- assign_attributes(record.symbolize_keys) if record
34
+ set_sti_type
35
+ assign_attributes(record) if record
37
36
  reset_changes
38
37
 
39
38
  return unless options.key?(:new_record)
@@ -3,8 +3,10 @@ module Modis
3
3
  def self.included(base)
4
4
  base.extend ClassMethods
5
5
  base.instance_eval do
6
- after__internal_create :track
7
- before__internal_destroy :untrack
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
- # :nodoc:
32
- def sti_child?
33
- @sti_child == true
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
- return @namespace if @namespace
39
- @namespace = name.split('::').map(&:underscore).join(':')
34
+ @namespace ||= name.split('::').map(&:underscore).join(':')
40
35
  end
41
36
 
42
- attr_writer :namespace
37
+ def namespace=(value)
38
+ @namespace = value
39
+ @absolute_namespace = nil
40
+ end
43
41
 
44
42
  def absolute_namespace
45
- parts = [Modis.config.namespace, namespace]
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
- def coerce_from_persistence(value)
66
- YAML.load(value)
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
- run_callbacks :_internal_destroy do
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(attribute, value)
126
- ensure_type(attribute, value)
127
- YAML.dump(value)
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
- run_callbacks "_internal_#{callback}" do
177
- attrs = []
178
- attributes.each { |k, v| attrs << k << coerce_for_persistence(k, v) }
179
- future = redis.hmset(self.class.key_for(id), attrs)
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 set_id
189
- Modis.with_connection do |redis|
190
- self.id = redis.incr("#{self.class.absolute_namespace}_id_seq")
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
- def track
195
- Modis.with_connection { |redis| redis.sadd(self.class.key_for(:all), id) }
224
+ attrs
196
225
  end
197
226
 
198
- def untrack
199
- Modis.with_connection { |redis| redis.srem(self.class.key_for(:all), id) }
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