related 0.2.1 → 0.3.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/CHANGELOG +6 -0
- data/README.md +147 -50
- data/lib/related/entity.rb +225 -107
- data/lib/related/exceptions.rb +1 -0
- data/lib/related/follower.rb +8 -8
- data/lib/related/helpers.rb +3 -3
- data/lib/related/node.rb +18 -2
- data/lib/related/version.rb +1 -1
- data/lib/related.rb +1 -1
- data/test/active_model_test.rb +132 -0
- data/test/model_test.rb +49 -0
- data/test/related_test.rb +1 -9
- metadata +11 -11
data/CHANGELOG
CHANGED
data/README.md
CHANGED
|
@@ -11,7 +11,9 @@ use. The intention is not to compete with industrial grade graph databases
|
|
|
11
11
|
like Neo4j, but rather to be a replacement for a relational database when your
|
|
12
12
|
data is better described as a graph. For example when building social
|
|
13
13
|
software. Related is meant to be web scale, but ultimately relies on the
|
|
14
|
-
ability of Redis to scale (using Redis cluster for example).
|
|
14
|
+
ability of Redis to scale (using Redis cluster for example). Read more about
|
|
15
|
+
the philosophy behind Related in the
|
|
16
|
+
[Wiki](http://github.com/sutajio/related/wiki).
|
|
15
17
|
|
|
16
18
|
Setup
|
|
17
19
|
-----
|
|
@@ -22,8 +24,10 @@ Assuming you already have Redis installed:
|
|
|
22
24
|
|
|
23
25
|
Or add the gem to your Gemfile.
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
```ruby
|
|
28
|
+
require 'related'
|
|
29
|
+
Related.redis = 'redis://.../'
|
|
30
|
+
```
|
|
27
31
|
|
|
28
32
|
If you are using Rails, add the above to an initializer. If Redis is running
|
|
29
33
|
on localhost and on the default port the second line is not needed.
|
|
@@ -31,52 +35,69 @@ on localhost and on the default port the second line is not needed.
|
|
|
31
35
|
Example usage
|
|
32
36
|
-------------
|
|
33
37
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
38
|
+
```ruby
|
|
39
|
+
node = Related::Node.create(:name => 'Example', :popularity => 2.3)
|
|
40
|
+
node.new?
|
|
41
|
+
node.popularity = 100
|
|
42
|
+
node.attributes
|
|
43
|
+
node.has_attribute?(:popularity)
|
|
44
|
+
node.read_attribute(:popularity)
|
|
45
|
+
node.write_attribute(:popularity, 50)
|
|
46
|
+
node.save
|
|
47
|
+
node.persisted?
|
|
48
|
+
node = Related::Node.find(node.id)
|
|
49
|
+
node.destroy
|
|
50
|
+
node.destroyed?
|
|
51
|
+
|
|
52
|
+
node1 = Related::Node.create
|
|
53
|
+
node2 = Related::Node.create
|
|
54
|
+
rel = Related::Relationship.create(:friends, node1, node2, :have_met => true)
|
|
55
|
+
|
|
56
|
+
n = Related::Node.find(node1.id)
|
|
57
|
+
nn = Related::Node.find(node1.id, node2.id)
|
|
58
|
+
|
|
59
|
+
n = Related::Node.find(node1.id, :fields => [:name])
|
|
60
|
+
nn = Related::Node.find(node1.id, node2.id, :fields => [:name])
|
|
61
|
+
```
|
|
50
62
|
|
|
51
63
|
Nodes and relationships are both sub-classes of the same base class and both
|
|
52
64
|
behave similar to an ActiveRecord object and can store attributes etc.
|
|
53
65
|
|
|
54
66
|
To query the graph:
|
|
55
67
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
```ruby
|
|
69
|
+
node.outgoing(:friends)
|
|
70
|
+
node.incoming(:friends)
|
|
71
|
+
node.outgoing(:friends).relationships
|
|
72
|
+
node.outgoing(:friends).nodes
|
|
73
|
+
node.outgoing(:friends).limit(5)
|
|
74
|
+
node.outgoing(:friends).options(:fields => ..., :model => ...)
|
|
75
|
+
node1.path_to(node2).outgoing(:friends).depth(3)
|
|
76
|
+
node1.shortest_path_to(node2).outgoing(:friends).depth(3)
|
|
77
|
+
```
|
|
63
78
|
|
|
64
79
|
To get the results from a query:
|
|
65
80
|
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
```ruby
|
|
82
|
+
node.outgoing(:friends).to_a
|
|
83
|
+
node.outgoing(:friends).count (or .size, which is memoized)
|
|
84
|
+
```
|
|
68
85
|
|
|
69
86
|
You can also do set operations, like union, diff and intersect:
|
|
70
87
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
88
|
+
```ruby
|
|
89
|
+
node1.outgoing(:friends).union(node2.outgoing(:friends))
|
|
90
|
+
node1.outgoing(:friends).diff(node2.outgoing(:friends))
|
|
91
|
+
node1.outgoing(:friends).intersect(node2.outgoing(:friends))
|
|
92
|
+
```
|
|
74
93
|
|
|
75
94
|
Relationships are sorted based on when they were created, which means you can
|
|
76
95
|
paginate them:
|
|
77
96
|
|
|
78
|
-
|
|
79
|
-
|
|
97
|
+
```ruby
|
|
98
|
+
node.outgoing(:friends).relationships.per_page(100).page(1)
|
|
99
|
+
node.outgoing(:friends).relationships.per_page(100).page(rel)
|
|
100
|
+
```
|
|
80
101
|
|
|
81
102
|
The second form paginates based on the id of the last relationship on the
|
|
82
103
|
previous page. Useful for cases where explicit page numbers are not
|
|
@@ -87,7 +108,79 @@ without going through the extra step of iterating through the relationship
|
|
|
87
108
|
objects you will only get random nodes. Thus you can use .limit (or .per_page)
|
|
88
109
|
like this to get a random selection of nodes:
|
|
89
110
|
|
|
90
|
-
|
|
111
|
+
```ruby
|
|
112
|
+
node.outgoing(:friends).nodes.limit(5)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Properties
|
|
116
|
+
----------
|
|
117
|
+
|
|
118
|
+
All Node and Relationship attributes are stored as strings in Redis, but you
|
|
119
|
+
can easily create your own subclass and define your own custom serialization
|
|
120
|
+
behavior. You can either just override the getter and setter methods for the
|
|
121
|
+
attribute you need to convert or you can use the `property` method to define
|
|
122
|
+
the semantics and let Related do the conversion for you.
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
class Event < Related::Node
|
|
126
|
+
property :title, String
|
|
127
|
+
property :attending_count, Integer
|
|
128
|
+
property :popularity, Float
|
|
129
|
+
property :start_date, DateTime
|
|
130
|
+
property :location do |value|
|
|
131
|
+
"http://maps.google.com/maps?q=#{value}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
An additional benefit of defining properties like this is that they get
|
|
137
|
+
included when you serialize the object to JSON or XML even when the attribute
|
|
138
|
+
hasn't been set.
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
event = Event.create(:title => 'Party!', :location => 'Stockholm')
|
|
142
|
+
event.as_json # => {"title"=>"Party!","attending_count"=>nil,"popularity"=>nil,"start_date"=>nil,"location"=>"http://maps.google.com/maps?q=Stockholm"}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
When querying the graph you may want the query to return the results as your
|
|
146
|
+
custom model class instead of as a Related::Node or Related::Relationship.
|
|
147
|
+
Related allows you to specify what model a specific node or relationship
|
|
148
|
+
should be instantiated as based on its attributes.
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
Related::Node.find(...,
|
|
152
|
+
:model => lambda {|attributes|
|
|
153
|
+
attributes['start_date'] ? Event : Related::Node
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
node.outgoing(:attending).options(
|
|
158
|
+
:model => lambda {|attributes|
|
|
159
|
+
attributes['start_date'] ? Event : Related::Node
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
ActiveModel
|
|
165
|
+
-----------
|
|
166
|
+
|
|
167
|
+
Related supports ActiveModel and includes some basic functionality in both
|
|
168
|
+
nodes and relationships like validations, callbacks, JSON and XML
|
|
169
|
+
serialization and translation support. You can easily extend your own sub
|
|
170
|
+
classes with the custom ActiveModel functionality that you need.
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
class Like < Related::Relationship
|
|
174
|
+
validates_presence_of :how_much
|
|
175
|
+
validates_numericality_of :how_much
|
|
176
|
+
|
|
177
|
+
after_save :invalidate_cache
|
|
178
|
+
|
|
179
|
+
def invalidate_cache
|
|
180
|
+
...
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
```
|
|
91
184
|
|
|
92
185
|
Follower
|
|
93
186
|
--------
|
|
@@ -95,22 +188,26 @@ Follower
|
|
|
95
188
|
Related includes a helper module called Related::Follower that you can include
|
|
96
189
|
in your node sub-class to get basic Twitter-like follow functionality:
|
|
97
190
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
191
|
+
```ruby
|
|
192
|
+
require 'related/follower'
|
|
193
|
+
|
|
194
|
+
class User < Related::Node
|
|
195
|
+
include Related::Follower
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
user1 = User.create
|
|
199
|
+
user2 = User.create
|
|
200
|
+
|
|
201
|
+
user1.follow!(user2)
|
|
202
|
+
user1.unfollow!(user2)
|
|
203
|
+
user2.followers
|
|
204
|
+
user1.following
|
|
205
|
+
user1.friends
|
|
206
|
+
user2.followed_by?(user1)
|
|
207
|
+
user1.following?(user2)
|
|
208
|
+
user2.followers_count
|
|
209
|
+
user1.following_count
|
|
210
|
+
```
|
|
114
211
|
|
|
115
212
|
The two nodes does not need to be of the same type. You can for example have
|
|
116
213
|
a User following a Page or whatever makes sense in your app.
|
data/lib/related/entity.rb
CHANGED
|
@@ -1,134 +1,252 @@
|
|
|
1
1
|
module Related
|
|
2
2
|
class Entity
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@attributes[
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
3
|
+
extend ActiveModel::Naming
|
|
4
|
+
extend ActiveModel::Callbacks
|
|
5
|
+
include ActiveModel::Conversion
|
|
6
|
+
include ActiveModel::Validations
|
|
7
|
+
include ActiveModel::Serializers::JSON
|
|
8
|
+
include ActiveModel::Serializers::Xml
|
|
9
|
+
include ActiveModel::Translation
|
|
10
|
+
|
|
11
|
+
self.include_root_in_json = false
|
|
12
|
+
|
|
13
|
+
define_model_callbacks :create, :update, :destroy, :save
|
|
14
|
+
|
|
15
|
+
attr_reader :id
|
|
16
|
+
attr_reader :attributes
|
|
17
|
+
|
|
18
|
+
def initialize(attributes = {})
|
|
19
|
+
@attributes = {}
|
|
20
|
+
attributes.each do |key,value|
|
|
21
|
+
serializer = self.class.property_serializer(key)
|
|
22
|
+
@attributes[key.to_s] = serializer ?
|
|
23
|
+
serializer.to_string(value) : value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_s
|
|
28
|
+
self.id
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def attributes
|
|
32
|
+
@attributes ||= {}
|
|
33
|
+
self.class.properties.inject({}) { |memo,key|
|
|
34
|
+
memo[key.to_s] = nil
|
|
35
|
+
memo
|
|
36
|
+
}.merge(@attributes.inject({}) { |memo,(k,v)|
|
|
37
|
+
memo[k.to_s] = v
|
|
38
|
+
memo
|
|
39
|
+
}.merge('id' => self.id))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def read_attribute(name)
|
|
43
|
+
@attributes ||= {}
|
|
44
|
+
@attributes[name.to_s] || @attributes[name]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def write_attribute(name, value)
|
|
48
|
+
@attributes ||= {}
|
|
49
|
+
@attributes[name.to_s] = value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def has_attribute?(name)
|
|
53
|
+
@attributes ||= {}
|
|
54
|
+
@attributes.has_key?(name.to_s) ||
|
|
55
|
+
@attributes.has_key?(name) ||
|
|
56
|
+
@properties.has_key?(name.to_sym)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def method_missing(sym, *args, &block)
|
|
60
|
+
if sym.to_s =~ /=$/
|
|
61
|
+
name = sym.to_s[0..-2]
|
|
62
|
+
serializer = self.class.property_serializer(name)
|
|
63
|
+
write_attribute(name,
|
|
64
|
+
serializer ? serializer.to_string(args.first) :
|
|
65
|
+
args.first)
|
|
66
|
+
else
|
|
67
|
+
serializer = self.class.property_serializer(sym)
|
|
68
|
+
serializer ? serializer.from_string(read_attribute(sym)) :
|
|
69
|
+
read_attribute(sym)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ==(other)
|
|
74
|
+
other.is_a?(Related::Entity) && self.to_key == other.to_key
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def new?
|
|
78
|
+
@id.nil? ? true : false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
alias new_record? new?
|
|
82
|
+
|
|
83
|
+
def persisted?
|
|
84
|
+
!new?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def destroyed?
|
|
88
|
+
@destroyed
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def save
|
|
92
|
+
create_or_update
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def destroy
|
|
96
|
+
delete
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.create(attributes = {})
|
|
100
|
+
self.new(attributes).save
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.find(*args)
|
|
104
|
+
options = args.size > 1 && args.last.is_a?(Hash) ? args.pop : {}
|
|
105
|
+
args.size == 1 && args.first.is_a?(String) ?
|
|
106
|
+
find_one(args.first, options) :
|
|
107
|
+
find_many(args.flatten, options)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.property(name, klass=nil, &block)
|
|
111
|
+
@properties ||= {}
|
|
112
|
+
@properties[name.to_sym] = Serializer.new(klass, block)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.properties
|
|
116
|
+
@properties ? @properties.keys : []
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def load_attributes(id, attributes)
|
|
122
|
+
@id = id
|
|
123
|
+
@attributes = attributes
|
|
124
|
+
self
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def create_or_update
|
|
128
|
+
run_callbacks :save do
|
|
129
|
+
new? ? create : update
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def create
|
|
134
|
+
run_callbacks :create do
|
|
135
|
+
raise Related::ValidationsFailed, self unless valid?
|
|
67
136
|
@id = Related.generate_id
|
|
68
|
-
@attributes
|
|
137
|
+
@attributes ||= {}
|
|
138
|
+
@attributes.merge!('created_at' => Time.now.utc.iso8601)
|
|
69
139
|
Related.redis.hmset(@id, *@attributes.to_a.flatten)
|
|
70
|
-
self
|
|
71
140
|
end
|
|
141
|
+
self
|
|
142
|
+
end
|
|
72
143
|
|
|
73
|
-
|
|
74
|
-
|
|
144
|
+
def update
|
|
145
|
+
run_callbacks :update do
|
|
146
|
+
raise Related::ValidationsFailed, self unless valid?
|
|
147
|
+
@attributes ||= {}
|
|
148
|
+
@attributes.merge!('updated_at' => Time.now.utc.iso8601)
|
|
75
149
|
Related.redis.hmset(@id, *@attributes.to_a.flatten)
|
|
76
|
-
self
|
|
77
150
|
end
|
|
151
|
+
self
|
|
152
|
+
end
|
|
78
153
|
|
|
79
|
-
|
|
154
|
+
def delete
|
|
155
|
+
run_callbacks :destroy do
|
|
80
156
|
Related.redis.del(id)
|
|
81
157
|
@destroyed = true
|
|
82
|
-
self
|
|
83
158
|
end
|
|
159
|
+
self
|
|
160
|
+
end
|
|
84
161
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
end
|
|
92
|
-
attributes
|
|
162
|
+
def self.find_fields(id, fields)
|
|
163
|
+
res = Related.redis.hmget(id.to_s, *fields)
|
|
164
|
+
if res
|
|
165
|
+
attributes = {}
|
|
166
|
+
res.each_with_index do |value, i|
|
|
167
|
+
attributes[fields[i]] = value
|
|
93
168
|
end
|
|
169
|
+
attributes
|
|
94
170
|
end
|
|
171
|
+
end
|
|
95
172
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
end
|
|
173
|
+
def self.find_one(id, options = {})
|
|
174
|
+
attributes = options[:fields] ?
|
|
175
|
+
find_fields(id, options[:fields]) :
|
|
176
|
+
Related.redis.hgetall(id.to_s)
|
|
177
|
+
if attributes.empty?
|
|
178
|
+
if Related.redis.exists(id) == false
|
|
179
|
+
raise Related::NotFound, id
|
|
104
180
|
end
|
|
105
|
-
self.new(id, attributes)
|
|
106
181
|
end
|
|
182
|
+
klass = options[:model] ? options[:model].call(attributes) : self
|
|
183
|
+
klass.new.send(:load_attributes, id, attributes)
|
|
184
|
+
end
|
|
107
185
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if options[:fields]
|
|
112
|
-
Related.redis.hmget(id.to_s, *options[:fields])
|
|
113
|
-
else
|
|
114
|
-
Related.redis.hgetall(id.to_s)
|
|
115
|
-
end
|
|
116
|
-
}
|
|
117
|
-
end
|
|
118
|
-
objects = []
|
|
119
|
-
ids.each_with_index do |id,i|
|
|
186
|
+
def self.find_many(ids, options = {})
|
|
187
|
+
res = Related.redis.pipelined do
|
|
188
|
+
ids.each {|id|
|
|
120
189
|
if options[:fields]
|
|
121
|
-
|
|
122
|
-
res[i].each_with_index do |value, i|
|
|
123
|
-
attributes[options[:fields][i]] = value
|
|
124
|
-
end
|
|
125
|
-
objects << self.new(id, attributes)
|
|
190
|
+
Related.redis.hmget(id.to_s, *options[:fields])
|
|
126
191
|
else
|
|
127
|
-
|
|
192
|
+
Related.redis.hgetall(id.to_s)
|
|
193
|
+
end
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
objects = []
|
|
197
|
+
ids.each_with_index do |id,i|
|
|
198
|
+
if options[:fields]
|
|
199
|
+
attributes = {}
|
|
200
|
+
res[i].each_with_index do |value, i|
|
|
201
|
+
attributes[options[:fields][i]] = value
|
|
128
202
|
end
|
|
203
|
+
klass = options[:model] ? options[:model].call(attributes) : self
|
|
204
|
+
objects << klass.new.send(:load_attributes, id, attributes)
|
|
205
|
+
else
|
|
206
|
+
attributes = Hash[*res[i]]
|
|
207
|
+
klass = options[:model] ? options[:model].call(attributes) : self
|
|
208
|
+
objects << klass.new.send(:load_attributes, id, attributes)
|
|
129
209
|
end
|
|
130
|
-
objects
|
|
131
210
|
end
|
|
211
|
+
objects
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def self.property_serializer(property)
|
|
215
|
+
@properties ||= {}
|
|
216
|
+
@properties[property.to_sym]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
class Serializer
|
|
220
|
+
def initialize(klass, block = nil)
|
|
221
|
+
@klass = klass
|
|
222
|
+
@block = block
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def to_string(value)
|
|
226
|
+
case @klass.to_s
|
|
227
|
+
when 'DateTime', 'Time'
|
|
228
|
+
value.iso8601
|
|
229
|
+
else
|
|
230
|
+
value.to_s
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def from_string(value)
|
|
235
|
+
value = case @klass.to_s
|
|
236
|
+
when 'String'
|
|
237
|
+
value.to_s
|
|
238
|
+
when 'Integer'
|
|
239
|
+
value.to_i
|
|
240
|
+
when 'Float'
|
|
241
|
+
value.to_f
|
|
242
|
+
when 'DateTime', 'Time'
|
|
243
|
+
Time.parse(value)
|
|
244
|
+
else
|
|
245
|
+
value
|
|
246
|
+
end unless value.nil?
|
|
247
|
+
@block ? @block.call(value) : value
|
|
248
|
+
end
|
|
249
|
+
end
|
|
132
250
|
|
|
133
251
|
end
|
|
134
252
|
end
|
data/lib/related/exceptions.rb
CHANGED
data/lib/related/follower.rb
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
module Related
|
|
2
2
|
module Follower
|
|
3
|
-
def follow!(
|
|
4
|
-
Related::Relationship.create(:follow, self,
|
|
3
|
+
def follow!(other)
|
|
4
|
+
Related::Relationship.create(:follow, self, other)
|
|
5
5
|
end
|
|
6
6
|
|
|
7
|
-
def unfollow!(
|
|
7
|
+
def unfollow!(other)
|
|
8
8
|
self.following.relationships.each do |rel|
|
|
9
|
-
if rel.end_node_id ==
|
|
9
|
+
if rel.end_node_id == other.id
|
|
10
10
|
rel.destroy
|
|
11
11
|
end
|
|
12
12
|
end
|
|
@@ -24,12 +24,12 @@ module Related
|
|
|
24
24
|
self.followers.intersect(self.following)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def followed_by?(
|
|
28
|
-
self.followers.include?(
|
|
27
|
+
def followed_by?(other)
|
|
28
|
+
self.followers.include?(other)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def following?(
|
|
32
|
-
self.following.include?(
|
|
31
|
+
def following?(other)
|
|
32
|
+
self.following.include?(other)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def followers_count
|
data/lib/related/helpers.rb
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
require 'base64'
|
|
2
|
-
require 'digest/
|
|
2
|
+
require 'digest/md5'
|
|
3
3
|
|
|
4
4
|
module Related
|
|
5
5
|
module Helpers
|
|
@@ -7,8 +7,8 @@ module Related
|
|
|
7
7
|
# Generate a unique id
|
|
8
8
|
def generate_id
|
|
9
9
|
Base64.encode64(
|
|
10
|
-
Digest::
|
|
11
|
-
).gsub('/','x').gsub('+','y').gsub('=','').strip
|
|
10
|
+
Digest::MD5.digest("#{Time.now}-#{rand}")
|
|
11
|
+
).gsub('/','x').gsub('+','y').gsub('=','').strip
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
end
|
data/lib/related/node.rb
CHANGED
|
@@ -13,6 +13,12 @@ module Related
|
|
|
13
13
|
query
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def options(opt)
|
|
17
|
+
query = self.query
|
|
18
|
+
query.options = opt
|
|
19
|
+
query
|
|
20
|
+
end
|
|
21
|
+
|
|
16
22
|
def outgoing(type)
|
|
17
23
|
query = self.query
|
|
18
24
|
query.relationship_type = type
|
|
@@ -86,11 +92,13 @@ module Related
|
|
|
86
92
|
attr_writer :include_start_node
|
|
87
93
|
attr_writer :destination
|
|
88
94
|
attr_writer :search_algorithm
|
|
95
|
+
attr_writer :options
|
|
89
96
|
|
|
90
97
|
def initialize(node)
|
|
91
98
|
@node = node
|
|
92
99
|
@result_type = :nodes
|
|
93
100
|
@depth = 4
|
|
101
|
+
@options = {}
|
|
94
102
|
end
|
|
95
103
|
|
|
96
104
|
def each(&block)
|
|
@@ -104,9 +112,9 @@ module Related
|
|
|
104
112
|
def to_a
|
|
105
113
|
perform_query unless @result
|
|
106
114
|
if @result_type == :nodes
|
|
107
|
-
Related::Node.find(@result)
|
|
115
|
+
Related::Node.find(@result, @options)
|
|
108
116
|
else
|
|
109
|
-
Related::Relationship.find(@result)
|
|
117
|
+
Related::Relationship.find(@result, @options)
|
|
110
118
|
end
|
|
111
119
|
end
|
|
112
120
|
|
|
@@ -153,6 +161,14 @@ module Related
|
|
|
153
161
|
self
|
|
154
162
|
end
|
|
155
163
|
|
|
164
|
+
def as_json(options = {})
|
|
165
|
+
self.to_a
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def to_json(options = {})
|
|
169
|
+
self.as_json.to_json(options)
|
|
170
|
+
end
|
|
171
|
+
|
|
156
172
|
protected
|
|
157
173
|
|
|
158
174
|
def page_start
|
data/lib/related/version.rb
CHANGED
data/lib/related.rb
CHANGED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
require File.expand_path('test/test_helper')
|
|
2
|
+
|
|
3
|
+
class ActiveModelTest < ActiveModel::TestCase
|
|
4
|
+
include ActiveModel::Lint::Tests
|
|
5
|
+
|
|
6
|
+
class Like < Related::Relationship
|
|
7
|
+
validates_numericality_of :how_much, :allow_nil => true
|
|
8
|
+
|
|
9
|
+
before_save :before_save_callback
|
|
10
|
+
after_save :after_save_callback
|
|
11
|
+
before_create :before_create_callback
|
|
12
|
+
after_create :after_create_callback
|
|
13
|
+
before_update :before_update_callback
|
|
14
|
+
after_update :after_update_callback
|
|
15
|
+
before_destroy :before_destroy_callback
|
|
16
|
+
after_destroy :after_destroy_callback
|
|
17
|
+
|
|
18
|
+
attr_reader :before_save_was_called
|
|
19
|
+
attr_reader :after_save_was_called
|
|
20
|
+
attr_reader :before_create_was_called
|
|
21
|
+
attr_reader :after_create_was_called
|
|
22
|
+
attr_reader :before_update_was_called
|
|
23
|
+
attr_reader :after_update_was_called
|
|
24
|
+
attr_reader :before_destroy_was_called
|
|
25
|
+
attr_reader :after_destroy_was_called
|
|
26
|
+
|
|
27
|
+
def before_save_callback
|
|
28
|
+
@before_save_was_called = true
|
|
29
|
+
end
|
|
30
|
+
def after_save_callback
|
|
31
|
+
@after_save_was_called = true
|
|
32
|
+
end
|
|
33
|
+
def before_create_callback
|
|
34
|
+
@before_create_was_called = true
|
|
35
|
+
end
|
|
36
|
+
def after_create_callback
|
|
37
|
+
@after_create_was_called = true
|
|
38
|
+
end
|
|
39
|
+
def before_update_callback
|
|
40
|
+
@before_update_was_called = true
|
|
41
|
+
end
|
|
42
|
+
def after_update_callback
|
|
43
|
+
@after_update_was_called = true
|
|
44
|
+
end
|
|
45
|
+
def before_destroy_callback
|
|
46
|
+
@before_destroy_was_called = true
|
|
47
|
+
end
|
|
48
|
+
def after_destroy_callback
|
|
49
|
+
@after_destroy_was_called = true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def setup
|
|
54
|
+
Related.redis.flushall
|
|
55
|
+
@model = Related::Entity.new
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_attributes_has_id
|
|
59
|
+
node = Related::Entity.create
|
|
60
|
+
assert_equal node.id, node.attributes['id']
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def test_can_return_json
|
|
64
|
+
node = Related::Entity.create(:name => 'test')
|
|
65
|
+
json = { :node => node }.to_json
|
|
66
|
+
json = JSON.parse(json)
|
|
67
|
+
assert_equal node.id, json['node']['id']
|
|
68
|
+
assert_equal node.name, json['node']['name']
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def test_query_can_return_json
|
|
72
|
+
node1 = Related::Node.create(:name => 'node1')
|
|
73
|
+
node2 = Related::Node.create(:name => 'node2')
|
|
74
|
+
Related::Relationship.create(:friends, node1, node2)
|
|
75
|
+
json = { :nodes => node1.outgoing(:friends) }.to_json
|
|
76
|
+
json = JSON.parse(json)
|
|
77
|
+
assert_equal node2.id, json['nodes'][0]['id']
|
|
78
|
+
assert_equal node2.name, json['nodes'][0]['name']
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_validations
|
|
82
|
+
like = Like.new(:how_much => 'not much')
|
|
83
|
+
assert_equal false, like.valid?
|
|
84
|
+
assert_equal [:how_much], like.errors.messages.keys
|
|
85
|
+
assert_raises Related::ValidationsFailed do
|
|
86
|
+
like.save
|
|
87
|
+
end
|
|
88
|
+
like.how_much = 1.0
|
|
89
|
+
assert_equal true, like.valid?
|
|
90
|
+
assert_nothing_raised do
|
|
91
|
+
like.save
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def test_save_callbacks
|
|
96
|
+
like = Like.new
|
|
97
|
+
assert_equal nil, like.before_save_was_called
|
|
98
|
+
assert_equal nil, like.after_save_was_called
|
|
99
|
+
like.save
|
|
100
|
+
assert_equal true, like.before_save_was_called
|
|
101
|
+
assert_equal true, like.after_save_was_called
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def test_create_callbacks
|
|
105
|
+
like = Like.new
|
|
106
|
+
assert_equal nil, like.before_create_was_called
|
|
107
|
+
assert_equal nil, like.after_create_was_called
|
|
108
|
+
like.save
|
|
109
|
+
assert_equal true, like.before_create_was_called
|
|
110
|
+
assert_equal true, like.after_create_was_called
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def test_update_callbacks
|
|
114
|
+
like = Like.new
|
|
115
|
+
like.save
|
|
116
|
+
assert_equal nil, like.before_update_was_called
|
|
117
|
+
assert_equal nil, like.after_update_was_called
|
|
118
|
+
like.save
|
|
119
|
+
assert_equal true, like.before_update_was_called
|
|
120
|
+
assert_equal true, like.after_update_was_called
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def test_create_callbacks
|
|
124
|
+
like = Like.new
|
|
125
|
+
assert_equal nil, like.before_destroy_was_called
|
|
126
|
+
assert_equal nil, like.after_destroy_was_called
|
|
127
|
+
like.destroy
|
|
128
|
+
assert_equal true, like.before_destroy_was_called
|
|
129
|
+
assert_equal true, like.after_destroy_was_called
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
end
|
data/test/model_test.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require File.expand_path('test/test_helper')
|
|
2
|
+
|
|
3
|
+
class ModelTest < ActiveModel::TestCase
|
|
4
|
+
|
|
5
|
+
class Event < Related::Node
|
|
6
|
+
property :attending_count, Integer
|
|
7
|
+
property :popularity, Float
|
|
8
|
+
property :start_date, DateTime
|
|
9
|
+
property :end_date, DateTime
|
|
10
|
+
property :location do |value|
|
|
11
|
+
"http://maps.google.com/maps?q=#{value}"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def setup
|
|
16
|
+
Related.redis.flushall
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_property_conversion
|
|
20
|
+
event = Event.create(
|
|
21
|
+
:attending_count => 42,
|
|
22
|
+
:popularity => 0.9,
|
|
23
|
+
:start_date => Time.parse('2011-01-01'),
|
|
24
|
+
:location => 'Stockholm')
|
|
25
|
+
event = Event.find(event.id)
|
|
26
|
+
assert_equal 42, event.attending_count
|
|
27
|
+
assert_equal 0.9, event.popularity
|
|
28
|
+
assert_equal Time.parse('2011-01-01'), event.start_date
|
|
29
|
+
assert_equal Time.parse('2011-01-01').iso8601, event.read_attribute(:start_date)
|
|
30
|
+
assert_equal nil, event.end_date
|
|
31
|
+
assert_equal "http://maps.google.com/maps?q=Stockholm", event.location
|
|
32
|
+
assert event.as_json.has_key?('end_date')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_model_factory
|
|
36
|
+
e1 = Event.create(:popularity => 0.0)
|
|
37
|
+
e2 = Event.create(:popularity => 1.0)
|
|
38
|
+
e1, e2 = Related::Node.find(e1.id, e2.id, :model =>
|
|
39
|
+
lambda {|attributes| attributes['popularity'].to_f > 0.5 ? Event : Related::Node })
|
|
40
|
+
assert_equal Related::Node, e1.class
|
|
41
|
+
assert_equal Event, e2.class
|
|
42
|
+
|
|
43
|
+
Related::Relationship.create(:test, e1, e2)
|
|
44
|
+
nodes = e1.outgoing(:test).nodes.options(:model =>
|
|
45
|
+
lambda {|attributes| attributes['popularity'].to_f > 0.5 ? Event : Related::Node }).to_a
|
|
46
|
+
assert_equal Event, nodes.first.class
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
end
|
data/test/related_test.rb
CHANGED
|
@@ -56,7 +56,7 @@ class RelatedTest < Test::Unit::TestCase
|
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def test_two_nodes_with_the_same_id_should_be_equal
|
|
59
|
-
assert_equal Related::Node.new('test', {}), Related::Node.new('test', {})
|
|
59
|
+
assert_equal Related::Node.new.send(:load_attributes, 'test', {}), Related::Node.new.send(:load_attributes, 'test', {})
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
def test_can_find_a_node_and_only_load_specific_attributes
|
|
@@ -265,14 +265,6 @@ class RelatedTest < Test::Unit::TestCase
|
|
|
265
265
|
assert_equal [node1], node2.incoming(:friends).intersect(node3.incoming(:friends)).to_a
|
|
266
266
|
end
|
|
267
267
|
|
|
268
|
-
def test_can_return_json
|
|
269
|
-
node = Related::Node.create(:name => 'test')
|
|
270
|
-
json = { :node => node }.to_json
|
|
271
|
-
json = JSON.parse(json)
|
|
272
|
-
assert_equal node.id, json['node']['id']
|
|
273
|
-
assert_equal node.name, json['node']['name']
|
|
274
|
-
end
|
|
275
|
-
|
|
276
268
|
def test_timestamps
|
|
277
269
|
node = Related::Node.create.save
|
|
278
270
|
node = Related::Node.find(node.id)
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: related
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
hash:
|
|
4
|
+
hash: 19
|
|
5
5
|
prerelease: false
|
|
6
6
|
segments:
|
|
7
7
|
- 0
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
version: 0.
|
|
8
|
+
- 3
|
|
9
|
+
- 0
|
|
10
|
+
version: 0.3.0
|
|
11
11
|
platform: ruby
|
|
12
12
|
authors:
|
|
13
13
|
- Niklas Holmgren
|
|
@@ -15,7 +15,7 @@ autorequire:
|
|
|
15
15
|
bindir: bin
|
|
16
16
|
cert_chain: []
|
|
17
17
|
|
|
18
|
-
date: 2011-
|
|
18
|
+
date: 2011-10-07 00:00:00 +02:00
|
|
19
19
|
default_executable:
|
|
20
20
|
dependencies:
|
|
21
21
|
- !ruby/object:Gem::Dependency
|
|
@@ -51,19 +51,17 @@ dependencies:
|
|
|
51
51
|
type: :runtime
|
|
52
52
|
version_requirements: *id002
|
|
53
53
|
- !ruby/object:Gem::Dependency
|
|
54
|
-
name:
|
|
54
|
+
name: activemodel
|
|
55
55
|
prerelease: false
|
|
56
56
|
requirement: &id003 !ruby/object:Gem::Requirement
|
|
57
57
|
none: false
|
|
58
58
|
requirements:
|
|
59
|
-
- - "
|
|
59
|
+
- - ">="
|
|
60
60
|
- !ruby/object:Gem::Version
|
|
61
|
-
hash:
|
|
61
|
+
hash: 3
|
|
62
62
|
segments:
|
|
63
|
-
- 1
|
|
64
|
-
- 0
|
|
65
63
|
- 0
|
|
66
|
-
version:
|
|
64
|
+
version: "0"
|
|
67
65
|
type: :runtime
|
|
68
66
|
version_requirements: *id003
|
|
69
67
|
description: Related is a Redis-backed high performance graph database.
|
|
@@ -88,7 +86,9 @@ files:
|
|
|
88
86
|
- lib/related/relationship.rb
|
|
89
87
|
- lib/related/version.rb
|
|
90
88
|
- lib/related.rb
|
|
89
|
+
- test/active_model_test.rb
|
|
91
90
|
- test/follower_test.rb
|
|
91
|
+
- test/model_test.rb
|
|
92
92
|
- test/performance_test.rb
|
|
93
93
|
- test/redis-test.conf
|
|
94
94
|
- test/related_test.rb
|