redisrecord 0.1
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/LICENSE +23 -0
- data/README.markdown +69 -0
- data/examples/demo.rb +60 -0
- data/examples/sphinx.conf +45 -0
- data/examples/users.yml +9 -0
- data/lib/redisrecord.rb +374 -0
- data/lib/redisrecord/extensions.rb +112 -0
- data/spec/basic_spec.rb +66 -0
- data/spec/finder_spec.rb +82 -0
- data/spec/relationships_spec.rb +62 -0
- data/spec/spec_helper.rb +31 -0
- metadata +74 -0
data/LICENSE
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
Copyright (c) 2009 Mauro Pompilio
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any
|
4
|
+
person obtaining a copy of this software and associated
|
5
|
+
documentation files (the "Software"), to deal in the
|
6
|
+
Software without restriction, including without limitation
|
7
|
+
the rights to use, copy, modify, merge, publish,
|
8
|
+
distribute, sublicense, and/or sell copies of the
|
9
|
+
Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice
|
13
|
+
shall be included in all copies or substantial portions of
|
14
|
+
the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
|
17
|
+
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
18
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
19
|
+
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
|
20
|
+
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
21
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
22
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
23
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.markdown
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
# RedisRecord
|
2
|
+
A "virtual" Object Relational Mapper on top of [Redis](http://redis.googlecode.com).
|
3
|
+
|
4
|
+
This is a proof-of-concept. Allows you to create schema-less data structures and <br/>
|
5
|
+
build relationships between them, using Redis as storage.
|
6
|
+
|
7
|
+
## Author
|
8
|
+
Mauro Pompilio <hackers.are.rockstars@gmail.com>
|
9
|
+
|
10
|
+
## License
|
11
|
+
MIT
|
12
|
+
|
13
|
+
## Example
|
14
|
+
|
15
|
+
class User < RedisRecord::Base
|
16
|
+
database 15
|
17
|
+
has_many :posts
|
18
|
+
end
|
19
|
+
|
20
|
+
class Post < RedisRecord::Base
|
21
|
+
database 15
|
22
|
+
belongs_to :user
|
23
|
+
has_many :comments
|
24
|
+
end
|
25
|
+
|
26
|
+
class Comment < RedisRecord::Base
|
27
|
+
database 15
|
28
|
+
belongs_to :post
|
29
|
+
belongs_to :user
|
30
|
+
end
|
31
|
+
|
32
|
+
>> u = User.new
|
33
|
+
=> #<User:0xb761c3f0 @stored_attrs=#<Set: {}>, @cached_attrs={}, @opts={}>
|
34
|
+
>> u.name = 'Mauro'
|
35
|
+
=> "Mauro"
|
36
|
+
>> u.age = 25
|
37
|
+
=> 25
|
38
|
+
>> u.save
|
39
|
+
=> [:updated_at, :age, :name, :id, :created_at]
|
40
|
+
>> u.whatever = {:as_many_attributes => 'as you want'}
|
41
|
+
=> {:as_many_attributes=>"as you want"}
|
42
|
+
>> u.save
|
43
|
+
=> [:updated_at, :whatever]
|
44
|
+
>> u
|
45
|
+
=> #<User:0xb761c3f0 @stored_attrs=#<Set: {:updated_at, :age, :name, :whatever, :id, :created_at}>, @cached_attrs={:updated_at=>"1238173522.49843", :age=>25, :name=>"Mauro", :whatever=>{:as_many_attributes=>"as you want"}, :id=>1, :created_at=>"1238173522.49808"}, @opts={}>
|
46
|
+
>> u = User.find(1)
|
47
|
+
=> #<User:0xb760a2a4 @stored_attrs=#<Set: {:updated_at, :age, :name, :whatever, :id, :created_at}>, @cached_attrs={:updated_at=>"1238173566", :age=>"25", :name=>"Mauro", :whatever=>"as_many_attributesas you want", :id=>"1", :created_at=>"1238173522.49808"}, @opts={:stored=>true}>
|
48
|
+
>> p = Post.new
|
49
|
+
=> #<Post:0xb7608210 @stored_attrs=#<Set: {}>, @cached_attrs={:user_id=>nil}, @opts={}>
|
50
|
+
>> p.title = 'New Post'
|
51
|
+
=> "New Post"
|
52
|
+
>> p.user_id = u.id
|
53
|
+
=> "1"
|
54
|
+
>> p.save
|
55
|
+
=> [:updated_at, :title, :id, :user_id, :created_at:
|
56
|
+
>> p2 = Post.new
|
57
|
+
=> #<Post:0xb75e5ad0 @stored_attrs=#<Set: {}>, @cached_attrs={:user_id=>nil}, @opts={}>
|
58
|
+
>> p2.title = 'Another post'
|
59
|
+
=> "Another post"
|
60
|
+
>> p2.user_id = u.id
|
61
|
+
=> "1"
|
62
|
+
>> p2.save
|
63
|
+
=> [:updated_at, :title, :id, :user_id, :created_at]
|
64
|
+
>> p.user
|
65
|
+
=> #<User:0xb75ce754 @stored_attrs=#<Set: {:updated_at, :age, :name, :whatever, :id, :created_at}>, @cached_attrs={:updated_at=>"1238173566", :age=>"25", :name=>"Mauro", :whatever=>"as_many_attributesas you want", :id=>"1", :created_at=>"1238173522.49808"}, @opts={:stored=>true}>
|
66
|
+
>> p2.user
|
67
|
+
=> #<User:0xb75c8480 @stored_attrs=#<Set: {:updated_at, :age, :name, :whatever, :id, :created_at}>, @cached_attrs={:updated_at=>"1238173566", :age=>"25", :name=>"Mauro", :whatever=>"as_many_attributesas you want", :id=>"1", :created_at=>"1238173522.49808"}, @opts={:stored=>true}>
|
68
|
+
>> u.posts
|
69
|
+
=> [#<Post:0xb75c20a8 @stored_attrs=#<Set: {:updated_at, :title, :id, :user_id, :created_at}>, @cached_attrs={:updated_at=>"1238173641", :title=>"New Post", :id=>"1", :user_id=>"1", :created_at=>"1238173641.17936"}, @opts={:stored=>true}>, #<Post:0xb75bdb0c @stored_attrs=#<Set: {:updated_at, :title, :id, :user_id, :created_at}>, @cached_attrs={:updated_at=>"1238173858", :title=>"Another post", :id=>"2", :user_id=>"1", :created_at=>"1238173858.82325"}, @opts={:stored=>true}>]
|
data/examples/demo.rb
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
# = Sample file
|
2
|
+
require 'lib/redisrecord'
|
3
|
+
|
4
|
+
# example.rb demo class
|
5
|
+
class User < RedisRecord::Model
|
6
|
+
database 0
|
7
|
+
has_many :posts
|
8
|
+
#has_one :moderator
|
9
|
+
end
|
10
|
+
|
11
|
+
# example.rb demo class
|
12
|
+
class Post < RedisRecord::Model
|
13
|
+
database 1
|
14
|
+
belongs_to :user
|
15
|
+
has_many :comments
|
16
|
+
has_and_belongs_to_many :categories
|
17
|
+
end
|
18
|
+
|
19
|
+
# example.rb demo class
|
20
|
+
class Comment < RedisRecord::Model
|
21
|
+
database 15
|
22
|
+
belongs_to :post
|
23
|
+
belongs_to :user
|
24
|
+
end
|
25
|
+
|
26
|
+
# example.rb demo class
|
27
|
+
class Category < RedisRecord::Model
|
28
|
+
has_and_belongs_to_many :posts
|
29
|
+
end
|
30
|
+
|
31
|
+
# example.rb demo class
|
32
|
+
class Moderator < RedisRecord::Model
|
33
|
+
database 15
|
34
|
+
belongs_to :user
|
35
|
+
end
|
36
|
+
|
37
|
+
# Example
|
38
|
+
#p u = User.new
|
39
|
+
#p u.name = 'Mauro'
|
40
|
+
#p u.age = 25
|
41
|
+
#p u.save
|
42
|
+
#p u.whatever = {:as_many_attributes => 'as you want'}
|
43
|
+
#p u.save
|
44
|
+
#p u
|
45
|
+
#p u = User.find(1)
|
46
|
+
#p p = Post.new
|
47
|
+
#p p.title = 'New Post'
|
48
|
+
#p p.user_id = u.id
|
49
|
+
#p p.save
|
50
|
+
#p p2 = Post.new
|
51
|
+
#p p2.title = 'Another post'
|
52
|
+
#p p2.user_id = u.id
|
53
|
+
#p p2.save
|
54
|
+
#p p.user
|
55
|
+
#p p2.user
|
56
|
+
#p u.posts
|
57
|
+
|
58
|
+
# Flush DB
|
59
|
+
#r = Redis.new(:db => 15)
|
60
|
+
#r.flush_db
|
@@ -0,0 +1,45 @@
|
|
1
|
+
#
|
2
|
+
# Sphinx configuration file sample for RedisRecord
|
3
|
+
#
|
4
|
+
|
5
|
+
# data source definition
|
6
|
+
source redisrecord
|
7
|
+
{
|
8
|
+
type= xmlpipe2
|
9
|
+
xmlpipe_command= cat /tmp/redisindex.xml
|
10
|
+
}
|
11
|
+
|
12
|
+
# index definition
|
13
|
+
index redisindex
|
14
|
+
{
|
15
|
+
source= redisrecord
|
16
|
+
path= /tmp/redisindex
|
17
|
+
docinfo= extern
|
18
|
+
mlock= 0
|
19
|
+
morphology= none
|
20
|
+
min_word_len= 1
|
21
|
+
charset_type= utf-8
|
22
|
+
html_strip= 0
|
23
|
+
}
|
24
|
+
|
25
|
+
# indexer settings
|
26
|
+
indexer
|
27
|
+
{
|
28
|
+
mem_limit= 32M
|
29
|
+
}
|
30
|
+
|
31
|
+
# searchd settings
|
32
|
+
searchd
|
33
|
+
{
|
34
|
+
port= 3312
|
35
|
+
log= /tmp/searchd.log
|
36
|
+
query_log= /tmp/query.log
|
37
|
+
read_timeout= 5
|
38
|
+
max_children= 30
|
39
|
+
pid_file= /tmp/searchd.pid
|
40
|
+
max_matches= 1000
|
41
|
+
seamless_rotate= 1
|
42
|
+
preopen_indexes= 0
|
43
|
+
unlink_old= 1
|
44
|
+
}
|
45
|
+
# --eof--
|
data/examples/users.yml
ADDED
data/lib/redisrecord.rb
ADDED
@@ -0,0 +1,374 @@
|
|
1
|
+
# = RedisRecord
|
2
|
+
# A "virtual" Object Relational Mapper on top of Redis[http://redis.googlecode.com].
|
3
|
+
#
|
4
|
+
# This is a proof-of-concept. Allows you to create schema-less data structures and
|
5
|
+
# build relationships between them, using Redis as storage.
|
6
|
+
#
|
7
|
+
# == Main repository
|
8
|
+
# http://github.com/malditogeek/redisrecord/tree/master
|
9
|
+
#
|
10
|
+
# == Author
|
11
|
+
# Mauro Pompilio <hackers.are.rockstars@gmail.com>
|
12
|
+
#
|
13
|
+
# == License
|
14
|
+
# MIT
|
15
|
+
#
|
16
|
+
|
17
|
+
require 'activesupport'
|
18
|
+
require 'redis'
|
19
|
+
|
20
|
+
# = RedisRecord
|
21
|
+
module RedisRecord
|
22
|
+
|
23
|
+
# Not found exception.
|
24
|
+
class RecordNotFound < Exception; end
|
25
|
+
|
26
|
+
# Duplicate attribute exception.
|
27
|
+
class DuplicateAttribute < Exception; end
|
28
|
+
|
29
|
+
# Connection to Redis.
|
30
|
+
class RedisConnection < Redis; end
|
31
|
+
|
32
|
+
# Base class.
|
33
|
+
class Model
|
34
|
+
attr_reader :attrs, :stored_attrs
|
35
|
+
|
36
|
+
# Redis connection
|
37
|
+
@@redis = RedisConnection.new(:logger => Logger.new(STDOUT))
|
38
|
+
|
39
|
+
# Reflections
|
40
|
+
@@reflections = {}
|
41
|
+
|
42
|
+
# Class methods
|
43
|
+
class << self
|
44
|
+
|
45
|
+
# Generates reflections skeleton when the RedisRecord is inherited.
|
46
|
+
def inherited(klass)
|
47
|
+
@@reflections[klass.name.to_sym] = {}
|
48
|
+
[:belongs_to, :has_many].each do |r|
|
49
|
+
@@reflections[klass.name.to_sym][r] = []
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Retrieve records. Allowed:
|
54
|
+
# * :all
|
55
|
+
# * :first
|
56
|
+
# * :last
|
57
|
+
# * one or more IDs.
|
58
|
+
#
|
59
|
+
# Example:
|
60
|
+
# Post.find(:last)
|
61
|
+
# Post.find(1)
|
62
|
+
# Post.find([1,2,3])
|
63
|
+
#
|
64
|
+
# Allowed options:
|
65
|
+
# * :should_raise: If *true* raise RecordNotFound exception on missing records. Defauls is *false*.
|
66
|
+
def find(*args)
|
67
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
68
|
+
case args.first
|
69
|
+
when :all
|
70
|
+
find_all(options)
|
71
|
+
when :first
|
72
|
+
find_first(options)
|
73
|
+
when :last
|
74
|
+
find_last(options)
|
75
|
+
else
|
76
|
+
find_from_ids(args, options)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Sort class objects by the given attribute. Defaults are:
|
81
|
+
# * :order => 'ALPHA DESC'
|
82
|
+
# * :limit => 10
|
83
|
+
#
|
84
|
+
# Example:
|
85
|
+
# Post.sort_by(:updated_at) #=> Last 10 updated posts
|
86
|
+
# Post.sort_by(:id, :order => 'ALPHA ASC', :limit => 5) #=> First 5 posts
|
87
|
+
def sort_by(attribute, options={})
|
88
|
+
limit = options[:limit] || 10
|
89
|
+
offset = options[:offset] || 0
|
90
|
+
order = options[:order] || 'ALPHA DESC'
|
91
|
+
ids = @@redis.sort("#{name}:list",
|
92
|
+
:by => "#{name}:*:#{attribute}",
|
93
|
+
:limit => [offset, limit],
|
94
|
+
:order => order,
|
95
|
+
:get => "#{name}:*:id")
|
96
|
+
find_from_ids(ids, options)
|
97
|
+
end
|
98
|
+
|
99
|
+
def _connection
|
100
|
+
@@redis
|
101
|
+
end
|
102
|
+
|
103
|
+
def get_lookup(attr, value)
|
104
|
+
_connection.get("#{name}:lookup:#{attr}:#{value}")
|
105
|
+
end
|
106
|
+
|
107
|
+
private # class methods
|
108
|
+
|
109
|
+
# Captures the *class* missing methods.
|
110
|
+
def method_missing(*args)
|
111
|
+
case args[0].to_s
|
112
|
+
when /^find_by_/
|
113
|
+
lookup = args[0].to_s.gsub(/^find_by_/, '')
|
114
|
+
find(get_lookup(lookup, args[1]))
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def find_all(options)
|
119
|
+
all_ids = @@redis.list_range("#{name}:list", 0, -1)
|
120
|
+
[find_from_ids(all_ids, options)].flatten
|
121
|
+
end
|
122
|
+
|
123
|
+
def find_first(options)
|
124
|
+
first_id = @@redis.list_index("#{name}:list", 0)
|
125
|
+
find_from_ids(first_id, options)
|
126
|
+
end
|
127
|
+
|
128
|
+
def find_last(options)
|
129
|
+
first_id = @@redis.list_index("#{name}:list", -1)
|
130
|
+
find_from_ids(first_id, options)
|
131
|
+
end
|
132
|
+
|
133
|
+
def find_from_ids(ids, options={})
|
134
|
+
records = []
|
135
|
+
ids = [ids].flatten
|
136
|
+
ids.each do |id|
|
137
|
+
begin
|
138
|
+
redis_attrs = @@redis.set_members("#{name}:#{id}:attrs").to_a
|
139
|
+
raise if redis_attrs.empty?
|
140
|
+
stored_attrs = @@redis.mget(redis_attrs.map {|a| "#{name}:#{id}:#{a}"})
|
141
|
+
object_attrs = {}
|
142
|
+
redis_attrs.each_with_index {|a,i| object_attrs[a.to_sym] = stored_attrs[i]}
|
143
|
+
records << instantiate(object_attrs)
|
144
|
+
rescue
|
145
|
+
raise RecordNotFound.new("#{name}:#{id}") if options[:should_raise]
|
146
|
+
#records << nil
|
147
|
+
end
|
148
|
+
end
|
149
|
+
(ids.length == 1 ? records[0] : records)
|
150
|
+
end
|
151
|
+
|
152
|
+
# Select which database to be used.
|
153
|
+
# class Post < RedisRecord
|
154
|
+
# database 15
|
155
|
+
# end
|
156
|
+
def database(db)
|
157
|
+
puts 'Per class database selection is DISABLED.'
|
158
|
+
return false
|
159
|
+
#_connection.call_command ['select', db]
|
160
|
+
end
|
161
|
+
|
162
|
+
# Returns a new object with the given attributes hash.
|
163
|
+
def instantiate(instance_attrs={})
|
164
|
+
new(instance_attrs, :stored => true)
|
165
|
+
end
|
166
|
+
|
167
|
+
# Belongs_to relationship initialization.
|
168
|
+
# class Post < RedisRecord
|
169
|
+
# belongs_to :user
|
170
|
+
# end
|
171
|
+
def belongs_to(*klasses)
|
172
|
+
klasses.each do |klass|
|
173
|
+
add_reflection :belongs_to, klass
|
174
|
+
|
175
|
+
fkey = klass.to_s.foreign_key.to_sym
|
176
|
+
define_method("#{klass}") do
|
177
|
+
k = klass.to_s.classify.constantize
|
178
|
+
k.find(@cached_attrs[fkey])
|
179
|
+
end
|
180
|
+
define_method("#{klass}=") do |new_value|
|
181
|
+
k = klass.to_s.classify.constantize
|
182
|
+
if new_value.is_a?(k)
|
183
|
+
@cached_attrs[fkey] = new_value.id
|
184
|
+
k.find(new_value.id)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
# Has_many relationship initialization.
|
191
|
+
# class Post < RedisRecord
|
192
|
+
# has_many :comments
|
193
|
+
# end
|
194
|
+
def has_many(*klasses)
|
195
|
+
klasses.each do |klass|
|
196
|
+
add_reflection :has_many, klass
|
197
|
+
|
198
|
+
define_method("#{klass}") {
|
199
|
+
k = klass.to_s.classify.constantize
|
200
|
+
ids = @@redis.set_members("#{self.class.name}:#{@cached_attrs[:id]}:_#{klass.to_s.singularize}_ids").to_a
|
201
|
+
ids.empty? ? [] : [k.find(ids)].flatten
|
202
|
+
}
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
# Has_one relationship initialization.
|
207
|
+
# class Post < RedisRecord
|
208
|
+
# has_one :permalink
|
209
|
+
# end
|
210
|
+
#def has_one(klass)
|
211
|
+
# add_reflection :has_one, klass
|
212
|
+
# define_method("#{klass}") {
|
213
|
+
# k = klass.to_s.classify.constantize
|
214
|
+
# k.find(@@redis["#{self.class.name}:#{@cached_attrs[:id]}:_#{klass.to_s.singularize}_id"])
|
215
|
+
# }
|
216
|
+
#end
|
217
|
+
|
218
|
+
# Has_and_belongs_to_many relationship initialization.
|
219
|
+
def has_and_belongs_to_many(klass)
|
220
|
+
has_many klass
|
221
|
+
belongs_to klass.to_s.singularize
|
222
|
+
end
|
223
|
+
|
224
|
+
def add_reflection(reflection, klass)
|
225
|
+
@@reflections[self.name.to_sym][reflection] << klass
|
226
|
+
end
|
227
|
+
|
228
|
+
end # Class methods
|
229
|
+
|
230
|
+
# Instantiate a new object with the given *attrs* hash.
|
231
|
+
def initialize(attrs={},opts={})
|
232
|
+
@opts = opts.freeze
|
233
|
+
|
234
|
+
# Object attrs
|
235
|
+
@cached_attrs, @stored_attrs = {}, Set.new
|
236
|
+
add_attributes attrs
|
237
|
+
attrs.keys.each {|k| @stored_attrs << k.to_sym} if @opts[:stored]
|
238
|
+
add_foreign_keys_as_attributes
|
239
|
+
end
|
240
|
+
|
241
|
+
# Save the (non-stored) object attributes to Redis.
|
242
|
+
def save
|
243
|
+
# Autoincremental ID unless specified
|
244
|
+
unless @cached_attrs.include?(:id)
|
245
|
+
add_attribute :id, @@redis.incr("#{self.class.name}:autoincrement")
|
246
|
+
add_attribute :created_at, Time.now.to_f.to_s
|
247
|
+
add_attribute :updated_at, Time.now.to_f.to_s
|
248
|
+
@@redis.push_tail("#{self.class.name}:list", @cached_attrs[:id]) # List of all the class objects
|
249
|
+
end
|
250
|
+
|
251
|
+
# Store each @cached_attrs
|
252
|
+
stored = Set.new
|
253
|
+
(@cached_attrs.keys - @stored_attrs.to_a).each do |k|
|
254
|
+
stored << k
|
255
|
+
@stored_attrs << k
|
256
|
+
@@redis.set_add("#{self.class.name}:#{id}:attrs", k.to_s)
|
257
|
+
@@redis.set("#{self.class.name}:#{@cached_attrs[:id]}:#{k}", @cached_attrs[k])
|
258
|
+
end
|
259
|
+
|
260
|
+
# updated_at
|
261
|
+
@@redis.set("#{self.class.name}:#{@cached_attrs[:id]}:updated_at", Time.now.to_f.to_s)
|
262
|
+
stored << :updated_at
|
263
|
+
|
264
|
+
# Relationships
|
265
|
+
@@reflections[self.class.name.to_sym][:belongs_to].each do |klass|
|
266
|
+
@@redis.set_add("#{klass.to_s.camelize}:#{@cached_attrs[klass.to_s.foreign_key.to_sym]}:_#{self.class.name.underscore}_ids", @cached_attrs[:id])
|
267
|
+
end
|
268
|
+
|
269
|
+
return stored.to_a
|
270
|
+
end
|
271
|
+
|
272
|
+
def destroy
|
273
|
+
if @cached_attrs[:id]
|
274
|
+
@@redis.list_rm("#{self.class.name}:list", 0, @cached_attrs[:id])
|
275
|
+
@cached_attrs.keys.each do |k|
|
276
|
+
@@redis.delete("#{self.class.name}:#{@cached_attrs[:id]}:#{k}")
|
277
|
+
end
|
278
|
+
@@redis.delete("#{self.class.name}:#{id}:attrs")
|
279
|
+
|
280
|
+
# Reflections
|
281
|
+
@@reflections[self.class.name.to_sym][:belongs_to].each do |klass|
|
282
|
+
fkey = @cached_attrs["#{klass}_id".to_sym]
|
283
|
+
@@redis.set_delete("#{klass.to_s.camelize}:#{fkey}:_#{self.class.name.downcase}_ids", @cached_attrs[:id]) if fkey
|
284
|
+
end
|
285
|
+
|
286
|
+
@cached_attrs = {}
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
# Returns *true* if there are unsaved attributes.
|
291
|
+
# p = Post.new(:title => 'Lorem ipsum')
|
292
|
+
# p.save
|
293
|
+
# p.dirty? # => false
|
294
|
+
# p.body = 'Dolor sit amet'
|
295
|
+
# p.dirty? # => true
|
296
|
+
def dirty?
|
297
|
+
(@cached_attrs.keys - @stored_attrs.to_a).empty? ? false : true
|
298
|
+
end
|
299
|
+
|
300
|
+
# Returns *true* if the object wasn't stored before
|
301
|
+
def new_record?
|
302
|
+
@opts[:stored] == true ? false : true
|
303
|
+
end
|
304
|
+
|
305
|
+
# Current instance attributes
|
306
|
+
def attributes
|
307
|
+
@cached_attrs.keys
|
308
|
+
end
|
309
|
+
|
310
|
+
# Reload from store, keeps non-stored attributes.
|
311
|
+
def reload
|
312
|
+
attrs = @@redis.set_members("#{self.class.name}:#{@cached_attrs[:id]}:attrs").map(&:to_sym)
|
313
|
+
attrs.each do |attr|
|
314
|
+
@cached_attrs[attr] = @@redis.get("#{self.class.name}:#{@cached_attrs[:id]}:#{attr}")
|
315
|
+
end
|
316
|
+
self
|
317
|
+
end
|
318
|
+
|
319
|
+
# Force reload from store, wipes non-stored attributes.
|
320
|
+
def reload!
|
321
|
+
@cached_attrs = {:id => @cached_attrs[:id]}
|
322
|
+
reload
|
323
|
+
end
|
324
|
+
|
325
|
+
# Set a reverse lookup by attribute.
|
326
|
+
def set_lookup(attr)
|
327
|
+
self.class._connection.set("#{self.class.name}:lookup:#{attr}:#{self.send(attr)}", self.id) if self.id
|
328
|
+
end
|
329
|
+
|
330
|
+
private
|
331
|
+
|
332
|
+
# Captures the *instance* missing methods and converts it into instance attributes.
|
333
|
+
def method_missing(*args)
|
334
|
+
method = args[0].to_s
|
335
|
+
case args.length
|
336
|
+
when 2
|
337
|
+
k, v = method.delete('=').to_sym, args[1]
|
338
|
+
add_attribute k, v
|
339
|
+
end
|
340
|
+
end
|
341
|
+
|
342
|
+
# Add attributes to the instance cache and define the accessor methods
|
343
|
+
def add_attributes(hash)
|
344
|
+
hash.each_pair do |k,v|
|
345
|
+
k = k.to_sym
|
346
|
+
#raise DuplicateAttribute.new("#{k}") unless (k == :id or !self.respond_to?(k))
|
347
|
+
if k == :id or !self.respond_to?(k)
|
348
|
+
@cached_attrs[k] = v
|
349
|
+
meta = class << self; self; end
|
350
|
+
meta.send(:define_method, k) { @cached_attrs[k] }
|
351
|
+
meta.send(:define_method, "#{k}=") do |new_value|
|
352
|
+
@cached_attrs[k] = new_value.is_a?(RedisRecord::Model) ? new_value.id : new_value
|
353
|
+
@stored_attrs.delete(k)
|
354
|
+
end
|
355
|
+
end
|
356
|
+
end
|
357
|
+
hash
|
358
|
+
end
|
359
|
+
|
360
|
+
# Wraper around add_attributes
|
361
|
+
def add_attribute(key, value=nil)
|
362
|
+
add_attributes({key => value})
|
363
|
+
end
|
364
|
+
|
365
|
+
# Add the foreign key for the belongs_to relationships
|
366
|
+
def add_foreign_keys_as_attributes
|
367
|
+
@@reflections[self.class.name.to_sym][:belongs_to].each do |klass|
|
368
|
+
add_attribute klass.to_s.foreign_key.to_sym
|
369
|
+
end
|
370
|
+
end
|
371
|
+
|
372
|
+
end
|
373
|
+
|
374
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
# = RedisRecord extensions
|
2
|
+
#
|
3
|
+
# This extensions should be required manually and add the following specific funcionality:
|
4
|
+
#
|
5
|
+
# * populate_from_yaml: Allows you to populate a DB from a YAML file.
|
6
|
+
# * Sphinx extensions:
|
7
|
+
# * sphinx_export: Export records to xmlpipe2-compatible XML files.
|
8
|
+
# * search: Sphinx query interface for RedisRecord.
|
9
|
+
#
|
10
|
+
require 'yaml'
|
11
|
+
|
12
|
+
begin
|
13
|
+
require 'xml'
|
14
|
+
rescue LoadError
|
15
|
+
$stderr.puts '[error] Missing gem: "libxml-ruby". Try: sudo gem install libxml-ruby'
|
16
|
+
end
|
17
|
+
|
18
|
+
begin
|
19
|
+
require 'riddle'
|
20
|
+
rescue LoadError
|
21
|
+
$stderr.puts '[error] Missing gem: "riddle". Try: sudo gem install riddle'
|
22
|
+
end
|
23
|
+
|
24
|
+
module RedisRecord
|
25
|
+
class Base
|
26
|
+
class << self
|
27
|
+
# IMPORTANT: This method is part of the *RedisRecord extensions*.
|
28
|
+
#
|
29
|
+
# Accepts a YAML file as input to populate the database.
|
30
|
+
#
|
31
|
+
# A sample YAML file can be found in the *samples* directory.
|
32
|
+
def populate_from_yaml(yaml_file)
|
33
|
+
entries = YAML::load_file(yaml_file)
|
34
|
+
records = []
|
35
|
+
entries.keys.each do |k|
|
36
|
+
begin
|
37
|
+
r = new(entries[k])
|
38
|
+
r.save
|
39
|
+
records << r
|
40
|
+
rescue Exception => e
|
41
|
+
records << nil
|
42
|
+
end
|
43
|
+
end
|
44
|
+
records
|
45
|
+
end
|
46
|
+
|
47
|
+
# IMPORTANT: This method is part of the *RedisRecord extensions*.
|
48
|
+
#
|
49
|
+
# Generates an Sphinx xmlpipe2-compatible XML file ready to be
|
50
|
+
# indexed.
|
51
|
+
#
|
52
|
+
# Parameters:
|
53
|
+
# * records: An array of RedisRecord entries
|
54
|
+
# * outfile: Output XML file path. Default is '/tmp/redisindex.xml'
|
55
|
+
#
|
56
|
+
# Depends on: *libxml-ruby* gem.
|
57
|
+
#
|
58
|
+
# A sample *sphinx.conf* file can be found in the *samples* directory.
|
59
|
+
def sphinx_export(records, outfile='/tmp/redisindex.xml')
|
60
|
+
sphinx_docset = XML::Document.new()
|
61
|
+
sphinx_docset.root = XML::Node.new('sphinx:docset')
|
62
|
+
|
63
|
+
sphinx_schema = XML::Node.new('sphinx:schema')
|
64
|
+
records_attrs = records.map {|r| r.attrs }
|
65
|
+
records_attrs.flatten!.uniq!
|
66
|
+
[:created_at, :updated_at].each do |a|
|
67
|
+
records_attrs.delete(a)
|
68
|
+
sphinx_attr = XML::Node.new('sphinx:attr')
|
69
|
+
sphinx_attr.attributes['name'] = a.to_s
|
70
|
+
sphinx_attr.attributes['type'] = 'timestamp'
|
71
|
+
sphinx_schema << sphinx_attr
|
72
|
+
end
|
73
|
+
|
74
|
+
records_attrs.each do |a|
|
75
|
+
sphinx_field = XML::Node.new('sphinx:field')
|
76
|
+
sphinx_field.attributes['name']= a.to_s
|
77
|
+
sphinx_schema << sphinx_field
|
78
|
+
end
|
79
|
+
sphinx_attr = XML::Node.new('sphinx:field')
|
80
|
+
sphinx_attr.attributes['name'] = 'class'
|
81
|
+
sphinx_schema << sphinx_attr
|
82
|
+
|
83
|
+
sphinx_docset.root << sphinx_schema
|
84
|
+
|
85
|
+
records.each do |record|
|
86
|
+
sphinx_doc = XML::Node.new('sphinx:document')
|
87
|
+
sphinx_doc.attributes['id'] = record.id
|
88
|
+
sphinx_doc << XML::Node.new('class', record.class.name)
|
89
|
+
record.attrs.each {|key| sphinx_doc << XML::Node.new(key, record.send(key).to_s) }
|
90
|
+
sphinx_docset.root << sphinx_doc
|
91
|
+
end
|
92
|
+
|
93
|
+
sphinx_docset.save(outfile)
|
94
|
+
sphinx_docset
|
95
|
+
end
|
96
|
+
|
97
|
+
# IMPORTANT: This method is part of the *RedisRecord extensions*.
|
98
|
+
#
|
99
|
+
# Sphinx client for RedisRecord. Accepts Sphinx extended[http://www.sphinxsearch.com/docs/current.html#extended-syntax] query syntax.
|
100
|
+
#
|
101
|
+
# Depends on: *riddle* gem.
|
102
|
+
def search(*args)
|
103
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
104
|
+
client = Riddle::Client.new
|
105
|
+
client.match_mode = :extended
|
106
|
+
q = client.query("#{args} @class #{name}")
|
107
|
+
ids = q[:matches].map {|r| r[:doc]}
|
108
|
+
find(ids)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
data/spec/basic_spec.rb
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper'
|
2
|
+
|
3
|
+
describe "RedisRecord" do
|
4
|
+
|
5
|
+
before(:each) do
|
6
|
+
@c = Customer.new
|
7
|
+
end
|
8
|
+
|
9
|
+
after do
|
10
|
+
r = Redis.new
|
11
|
+
#r.select_db 15
|
12
|
+
r.flush_db
|
13
|
+
end
|
14
|
+
|
15
|
+
it "should allow to add any attribute to an instance" do
|
16
|
+
@c.name = 'foo'
|
17
|
+
@c.age = 25
|
18
|
+
@c.name.should == 'foo'
|
19
|
+
@c.age.should == 25
|
20
|
+
end
|
21
|
+
|
22
|
+
it "should have an attribute accesor with the instance attributes" do
|
23
|
+
@c.name = 'foo'
|
24
|
+
@c.age = 25
|
25
|
+
@c.attrs.map {|a| a.to_s }.sort.should == ['age', 'name']
|
26
|
+
end
|
27
|
+
|
28
|
+
it "should be 'dirty' if some of the instance attributes aren't saved" do
|
29
|
+
@c.name = 'foo'
|
30
|
+
@c.dirty?.should == true
|
31
|
+
end
|
32
|
+
|
33
|
+
it "should not be 'dirty' anymore once saved" do
|
34
|
+
@c.name = 'foo'
|
35
|
+
@c.save
|
36
|
+
@c.dirty?.should == false
|
37
|
+
end
|
38
|
+
|
39
|
+
it "should be 'dirty' again after add a new attribute or update an existing one" do
|
40
|
+
@c.name = 'foo'
|
41
|
+
@c.save
|
42
|
+
@c.age = 25
|
43
|
+
@c.dirty?.should == true
|
44
|
+
@c.save
|
45
|
+
@c.name = 'bar'
|
46
|
+
@c.dirty?.should == true
|
47
|
+
end
|
48
|
+
|
49
|
+
it "should have :id and timestamp attributes after saved" do
|
50
|
+
@c.name = 'foo'
|
51
|
+
saved_attributes = @c.save
|
52
|
+
saved_attributes.map {|a| a.to_s }.sort.should == ['created_at','id','name','updated_at']
|
53
|
+
end
|
54
|
+
|
55
|
+
it "should save only: the new and the updated attributes, and update the :updated_at timestamp" do
|
56
|
+
@c.name = 'foo'
|
57
|
+
@c.lastname = 'bar'
|
58
|
+
saved_attributes = @c.save
|
59
|
+
saved_attributes.map {|a| a.to_s }.sort.should == ['created_at','id','lastname','name','updated_at']
|
60
|
+
@c.age = 25
|
61
|
+
@c.lastname = 'baz'
|
62
|
+
new_saved_attributes = @c.save
|
63
|
+
new_saved_attributes.map {|a| a.to_s }.sort.should == ['age','lastname','updated_at']
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
data/spec/finder_spec.rb
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper'
|
2
|
+
|
3
|
+
describe "RedisRecord" do
|
4
|
+
|
5
|
+
before do
|
6
|
+
@c1 = Customer.new
|
7
|
+
@c1.name = 'Foo'
|
8
|
+
@c1.age = 25
|
9
|
+
@c1.save
|
10
|
+
|
11
|
+
@c2 = Customer.new
|
12
|
+
@c2.name = 'Bar'
|
13
|
+
@c2.age = 30
|
14
|
+
@c2.save
|
15
|
+
|
16
|
+
@c3 = Customer.new
|
17
|
+
@c3.name = 'Baz'
|
18
|
+
@c3.age = 35
|
19
|
+
@c3.save
|
20
|
+
end
|
21
|
+
|
22
|
+
after do
|
23
|
+
r = Redis.new
|
24
|
+
#r.select_db 15
|
25
|
+
r.flush_db
|
26
|
+
end
|
27
|
+
|
28
|
+
it "should find a stored Customer by id" do
|
29
|
+
customer = Customer.find(@c1.id)
|
30
|
+
customer.attrs.map {|a| a.to_s }.sort.should == ['age','created_at','id','name','updated_at']
|
31
|
+
customer.new_record?.should == false
|
32
|
+
customer.dirty?.should == false
|
33
|
+
end
|
34
|
+
|
35
|
+
it "should find an array of Customers by id" do
|
36
|
+
customers = Customer.find(@c1.id, @c2.id)
|
37
|
+
customers.length.should == 2
|
38
|
+
customers.each do |c|
|
39
|
+
c.attrs.map {|a| a.to_s }.sort.should == ['age','created_at','id','name','updated_at']
|
40
|
+
c.new_record?.should == false
|
41
|
+
c.dirty?.should == false
|
42
|
+
['Foo', 'Bar'].include?(c.name).should == true
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
it "should find the :first Customer" do
|
47
|
+
customer = Customer.find(:first)
|
48
|
+
customer.name.should == 'Foo'
|
49
|
+
end
|
50
|
+
|
51
|
+
it "should find the :last Customer" do
|
52
|
+
customer = Customer.find(:last)
|
53
|
+
customer.name.should == 'Baz'
|
54
|
+
end
|
55
|
+
|
56
|
+
it "should find :all the Customers" do
|
57
|
+
customers = Customer.find(:all)
|
58
|
+
customers.each do |c|
|
59
|
+
c.attrs.map {|a| a.to_s }.sort.should == ['age','created_at','id','name','updated_at']
|
60
|
+
c.new_record?.should == false
|
61
|
+
c.dirty?.should == false
|
62
|
+
[@c1.id, @c2.id, @c3.id].map {|id| id.to_s}.include?(c.id).should == true
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
it "should return the last 10 or less Customers in descendent order of creation when sorting by :created_at" do
|
67
|
+
customers = Customer.sort_by(:created_at)
|
68
|
+
names_by_created_at = ['Baz', 'Bar', 'Foo']
|
69
|
+
until customers.empty?
|
70
|
+
customers.pop.name.should == names_by_created_at.pop
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
it "should return the first 2 Customers in ascendent order when sorting by :age" do
|
75
|
+
customers = Customer.sort_by(:age, :limit => 2, :order => 'ALPHA ASC')
|
76
|
+
ordered_ages = ['25','30']
|
77
|
+
until customers.empty?
|
78
|
+
customers.pop.age.should == ordered_ages.pop
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper'
|
2
|
+
|
3
|
+
describe "RedisRecord" do
|
4
|
+
|
5
|
+
before do
|
6
|
+
# User
|
7
|
+
@u1 = User.new(:name => 'Foo')
|
8
|
+
@u1.save
|
9
|
+
#puts "User1 => #{@u1.inspect}"
|
10
|
+
|
11
|
+
# Posts
|
12
|
+
@p1 = Post.new(:title => 'Post1', :body => 'Lorem ipsum')
|
13
|
+
@p1.user_id = @u1.id
|
14
|
+
@p1.save
|
15
|
+
#puts "Post1 => #{@p1.inspect}"
|
16
|
+
@p2 = Post.new(:title => 'Post2', :body => 'Lorem ipsum')
|
17
|
+
@p2.user_id = @u1.id
|
18
|
+
@p2.save
|
19
|
+
#puts "Post2 => #{@p2.inspect}"
|
20
|
+
|
21
|
+
# Comments
|
22
|
+
@c1 = Comment.new(:text => 'Comment1|Post1')
|
23
|
+
@c1.user_id = @u1.id
|
24
|
+
@c1.post_id = @p1.id
|
25
|
+
@c1.save
|
26
|
+
#puts "Comment1 => #{@c1.inspect}"
|
27
|
+
@c2 = Comment.new(:text => 'Comment2|Post1')
|
28
|
+
@c2.user_id = @u1.id
|
29
|
+
@c2.post_id = @p1.id
|
30
|
+
@c2.save
|
31
|
+
#puts "Comment2 => #{@c2.inspect}"
|
32
|
+
end
|
33
|
+
|
34
|
+
after do
|
35
|
+
r = Redis.new
|
36
|
+
#r.select_db 15
|
37
|
+
r.flush_db
|
38
|
+
end
|
39
|
+
|
40
|
+
it "should find the Posts/Comments relationships through the :has_many methods of the User" do
|
41
|
+
posts = @u1.posts
|
42
|
+
posts.length.should == 2
|
43
|
+
posts.each do |p|
|
44
|
+
['Post1','Post2'].include?(p.title).should == true
|
45
|
+
end
|
46
|
+
comments = @u1.comments
|
47
|
+
comments.length.should == 2
|
48
|
+
comments.each do |c|
|
49
|
+
['Comment1|Post1','Comment2|Post1'].include?(c.text).should == true
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
it "should find the User relationship through the Post :belongs_to method" do
|
54
|
+
@p1.user.id.should == '1'
|
55
|
+
end
|
56
|
+
|
57
|
+
it "should find the User/Post relationships through the Comment :belongs_to methods" do
|
58
|
+
@c1.user.id.should == '1'
|
59
|
+
@c1.post.id.should == '1'
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
$TESTING=true
|
3
|
+
$:.push File.join(File.dirname(__FILE__), '..', 'lib')
|
4
|
+
require 'redisrecord'
|
5
|
+
|
6
|
+
# Spec helper
|
7
|
+
class Customer < RedisRecord::Model
|
8
|
+
#database 15
|
9
|
+
end
|
10
|
+
|
11
|
+
# Spec helper
|
12
|
+
class User < RedisRecord::Model
|
13
|
+
#database 15
|
14
|
+
has_many :posts
|
15
|
+
has_many :comments
|
16
|
+
end
|
17
|
+
|
18
|
+
# Spec helper
|
19
|
+
class Post < RedisRecord::Model
|
20
|
+
#database 15
|
21
|
+
belongs_to :user
|
22
|
+
has_many :comments
|
23
|
+
end
|
24
|
+
|
25
|
+
# Spec helper
|
26
|
+
class Comment < RedisRecord::Model
|
27
|
+
#database 15
|
28
|
+
belongs_to :post
|
29
|
+
belongs_to :user
|
30
|
+
end
|
31
|
+
|
metadata
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: redisrecord
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: "0.1"
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Mauro Pompilio
|
8
|
+
autorequire: redisrecord
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-11-25 00:00:00 +01:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: rspec
|
17
|
+
type: :runtime
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: "0"
|
24
|
+
version:
|
25
|
+
description: A 'virtual' ORM on top of Redis.
|
26
|
+
email: hackers.are.rockstars@gmail.com
|
27
|
+
executables: []
|
28
|
+
|
29
|
+
extensions: []
|
30
|
+
|
31
|
+
extra_rdoc_files:
|
32
|
+
- LICENSE
|
33
|
+
files:
|
34
|
+
- LICENSE
|
35
|
+
- README.markdown
|
36
|
+
- lib/redisrecord.rb
|
37
|
+
- lib/redisrecord/extensions.rb
|
38
|
+
- spec/spec_helper.rb
|
39
|
+
- spec/relationships_spec.rb
|
40
|
+
- spec/basic_spec.rb
|
41
|
+
- spec/finder_spec.rb
|
42
|
+
- examples/demo.rb
|
43
|
+
- examples/sphinx.conf
|
44
|
+
- examples/users.yml
|
45
|
+
has_rdoc: true
|
46
|
+
homepage: http://github.com/malditogeek/redisrecord
|
47
|
+
licenses: []
|
48
|
+
|
49
|
+
post_install_message:
|
50
|
+
rdoc_options: []
|
51
|
+
|
52
|
+
require_paths:
|
53
|
+
- lib
|
54
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
55
|
+
requirements:
|
56
|
+
- - ">="
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
version: "0"
|
59
|
+
version:
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
61
|
+
requirements:
|
62
|
+
- - ">="
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: "0"
|
65
|
+
version:
|
66
|
+
requirements: []
|
67
|
+
|
68
|
+
rubyforge_project:
|
69
|
+
rubygems_version: 1.3.5
|
70
|
+
signing_key:
|
71
|
+
specification_version: 2
|
72
|
+
summary: A 'virtual' ORM on top of Redis.
|
73
|
+
test_files: []
|
74
|
+
|