related 0.1 → 0.2

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG CHANGED
@@ -1,4 +1,10 @@
1
1
 
2
+ *0.2*
3
+
4
+ * Implemented set operations.
5
+ * Added Related::Follower module.
6
+ * Added pagination support (sorted relationships).
7
+
2
8
  *0.1*
3
9
 
4
10
  * First public release
data/README.md CHANGED
@@ -56,12 +56,55 @@ To get the results from a query:
56
56
  node.outgoing(:friends).to_a
57
57
  node.outgoing(:friends).count (or .size, which is memoized)
58
58
 
59
- You can also do set operations, like union, diff and intersect (not implemented yet):
59
+ You can also do set operations, like union, diff and intersect:
60
60
 
61
61
  node1.outgoing(:friends).union(node2.outgoing(:friends))
62
62
  node1.outgoing(:friends).diff(node2.outgoing(:friends))
63
63
  node1.outgoing(:friends).intersect(node2.outgoing(:friends))
64
64
 
65
+ Relationships are sorted based on when they were created, which means you can
66
+ paginate them:
67
+
68
+ node.outgoing(:friends).relationship.per_page(100).page(1)
69
+ node.outgoing(:friends).relationship.per_page(100).page(rel)
70
+
71
+ The second form paginates based on the id of the last relationship on the
72
+ previous page. Useful for cases where explicit page numbers are not
73
+ appropriate.
74
+
75
+ Pagination only works for relationships. If you want to access nodes directly
76
+ without going through the extra step of iterating through the relationship
77
+ objects you will only get random nodes. Thus you can use .limit (or .per_page)
78
+ like this to get a random selection of nodes:
79
+
80
+ node.outgoing(:friends).nodes.limit(5)
81
+
82
+ Follower
83
+ --------
84
+
85
+ Related includes a helper module called Related::Follower that you can include
86
+ in your node sub-class to get basic Twitter-like follow functionality:
87
+
88
+ class User < Related::Node
89
+ include Related::Follower
90
+ end
91
+
92
+ user1 = User.create
93
+ user2 = User.create
94
+
95
+ user1.follow!(user2)
96
+ user1.unfollow!(user2)
97
+ user2.followers
98
+ user1.following
99
+ user1.friends
100
+ user2.followed_by?(user1)
101
+ user1.following?(user2)
102
+ user2.followers_count
103
+ user1.following_count
104
+
105
+ The two nodes does not need to be of the same type. You can for example have
106
+ a User following a Page or whatever makes sense in your app.
107
+
65
108
  Development
66
109
  -----------
67
110
 
@@ -1,4 +1,5 @@
1
1
  module Related
2
2
  class RelatedException < RuntimeError; end
3
3
  class NotFound < RelatedException; end
4
+ class InvalidQuery < RelatedException; end
4
5
  end
@@ -0,0 +1,43 @@
1
+ module Related
2
+ module Follower
3
+ def follow!(user)
4
+ Related::Relationship.create(:follow, self, user)
5
+ end
6
+
7
+ def unfollow!(user)
8
+ self.following.relationships.each do |rel|
9
+ if rel.end_node_id == user.id
10
+ rel.destroy
11
+ end
12
+ end
13
+ end
14
+
15
+ def followers
16
+ self.incoming(:follow)
17
+ end
18
+
19
+ def following
20
+ self.outgoing(:follow)
21
+ end
22
+
23
+ def friends
24
+ self.followers.intersect(self.following)
25
+ end
26
+
27
+ def followed_by?(user)
28
+ self.followers.include?(user)
29
+ end
30
+
31
+ def following?(user)
32
+ self.following.include?(user)
33
+ end
34
+
35
+ def followers_count
36
+ self.followers.size
37
+ end
38
+
39
+ def following_count
40
+ self.following.size
41
+ end
42
+ end
43
+ end
data/lib/related/node.rb CHANGED
@@ -3,13 +3,13 @@ module Related
3
3
  module QueryMethods
4
4
  def relationships
5
5
  query = self.query
6
- query.entity_type = :relationships
6
+ query.result_type = :relationships
7
7
  query
8
8
  end
9
9
 
10
10
  def nodes
11
11
  query = self.query
12
- query.entity_type = :nodes
12
+ query.result_type = :nodes
13
13
  query
14
14
  end
15
15
 
@@ -33,6 +33,16 @@ module Related
33
33
  query
34
34
  end
35
35
 
36
+ def per_page(count)
37
+ self.limit(count)
38
+ end
39
+
40
+ def page(nr)
41
+ query = self.query
42
+ query.page = nr
43
+ query
44
+ end
45
+
36
46
  def depth(depth)
37
47
  query = self.query
38
48
  query.depth = depth
@@ -65,10 +75,13 @@ module Related
65
75
  class Query
66
76
  include QueryMethods
67
77
 
68
- attr_writer :entity_type
78
+ attr_reader :result
79
+
80
+ attr_writer :result_type
69
81
  attr_writer :relationship_type
70
82
  attr_writer :direction
71
83
  attr_writer :limit
84
+ attr_writer :page
72
85
  attr_writer :depth
73
86
  attr_writer :include_start_node
74
87
  attr_writer :destination
@@ -76,7 +89,7 @@ module Related
76
89
 
77
90
  def initialize(node)
78
91
  @node = node
79
- @entity_type = :nodes
92
+ @result_type = :nodes
80
93
  @depth = 4
81
94
  end
82
95
 
@@ -89,28 +102,18 @@ module Related
89
102
  end
90
103
 
91
104
  def to_a
92
- res = []
93
- if @destination
94
- res = self.send(@search_algorithm, [@node.id])
95
- res.shift unless @include_start_node
96
- return Related::Node.find(res)
105
+ perform_query unless @result
106
+ if @result_type == :nodes
107
+ Related::Node.find(@result)
97
108
  else
98
- if @limit
99
- res = (1..@limit.to_i).map { Related.redis.srandmember(key) }
100
- else
101
- res = Related.redis.smembers(key)
102
- end
103
- end
104
- res = Relationship.find(res)
105
- if @entity_type == :nodes
106
- res = Related::Node.find(res.map {|rel| @direction == :in ? rel.start_node_id : rel.end_node_id })
107
- res.unshift(@node) if @include_start_node
109
+ Related::Relationship.find(@result)
108
110
  end
109
- res
110
111
  end
111
112
 
112
113
  def count
113
- @count = Related.redis.scard(key)
114
+ @count = @result_type == :nodes ?
115
+ Related.redis.scard(key) :
116
+ Related.redis.zcard(key)
114
117
  @limit && @count > @limit ? @limit : @count
115
118
  end
116
119
 
@@ -118,24 +121,96 @@ module Related
118
121
  @count || count
119
122
  end
120
123
 
124
+ def include?(entity)
125
+ if @destination
126
+ self.to_a.include?(entity)
127
+ else
128
+ if entity.is_a?(Related::Node)
129
+ @result_type = :nodes
130
+ Related.redis.sismember(key, entity.to_s)
131
+ elsif entity.is_a?(Related::Relationship)
132
+ @result_type = :relationships
133
+ Related.redis.sismember(key, entity.to_s)
134
+ end
135
+ end
136
+ end
137
+
138
+ def union(query)
139
+ @result_type = :nodes
140
+ @result = Related.redis.sunion(key, query.key)
141
+ self
142
+ end
143
+
144
+ def diff(query)
145
+ @result_type = :nodes
146
+ @result = Related.redis.sdiff(key, query.key)
147
+ self
148
+ end
149
+
150
+ def intersect(query)
151
+ @result_type = :nodes
152
+ @result = Related.redis.sinter(key, query.key)
153
+ self
154
+ end
155
+
121
156
  protected
122
157
 
123
- def key
124
- "#{@node.id}:rel:#{@relationship_type}:#{@direction}"
158
+ def page_start
159
+ if @page.nil? || @page.to_i.to_s == @page.to_s
160
+ @page && @page.to_i != 1 ? (@page.to_i * @limit.to_i) - @limit.to_i : 0
161
+ else
162
+ rel = @page.is_a?(String) ? Related::Relationship.find(@page) : @page
163
+ rel.rank + 1
164
+ end
165
+ end
166
+
167
+ def page_end
168
+ page_start + @limit.to_i - 1
169
+ end
170
+
171
+ def key(node=nil)
172
+ if @result_type == :nodes
173
+ "#{node ? node.to_s : @node.to_s}:nodes:#{@relationship_type}:#{@direction}"
174
+ else
175
+ "#{node ? node.to_s : @node.to_s}:rel:#{@relationship_type}:#{@direction}"
176
+ end
125
177
  end
126
178
 
127
179
  def query
128
180
  self
129
181
  end
130
182
 
183
+ def perform_query
184
+ @result = []
185
+ if @destination
186
+ @result_type = :nodes
187
+ @result = self.send(@search_algorithm, [@node.id])
188
+ @result.shift unless @include_start_node
189
+ @result
190
+ else
191
+ if @result_type == :nodes
192
+ if @limit
193
+ @result = (1..@limit.to_i).map { Related.redis.srandmember(key) }
194
+ else
195
+ @result = Related.redis.smembers(key)
196
+ end
197
+ else
198
+ if @limit
199
+ @result = Related.redis.zrange(key, page_start, page_end)
200
+ else
201
+ @result = Related.redis.zrange(key, 0, -1)
202
+ end
203
+ end
204
+ end
205
+ end
206
+
131
207
  def depth_first(nodes, depth = 0)
132
208
  return [] if depth > @depth
133
209
  nodes.each do |node|
134
- key = "#{node}:nodes:#{@relationship_type}:#{@direction}"
135
- if Related.redis.sismember(key, @destination.id)
210
+ if Related.redis.sismember(key(node), @destination.id)
136
211
  return [node, @destination.id]
137
212
  else
138
- res = depth_first(Related.redis.smembers(key), depth+1)
213
+ res = depth_first(Related.redis.smembers(key(node)), depth+1)
139
214
  return [node] + res unless res.empty?
140
215
  end
141
216
  end
@@ -146,11 +221,10 @@ module Related
146
221
  return [] if depth > @depth
147
222
  shortest_path = []
148
223
  nodes.each do |node|
149
- key = "#{node}:nodes:#{@relationship_type}:#{@direction}"
150
- if Related.redis.sismember(key, @destination.id)
224
+ if Related.redis.sismember(key(node), @destination.id)
151
225
  return [node, @destination.id]
152
226
  else
153
- res = dijkstra(Related.redis.smembers(key), depth+1)
227
+ res = dijkstra(Related.redis.smembers(key(node)), depth+1)
154
228
  if res.size > 0
155
229
  res = [node] + res
156
230
  if res.size < shortest_path.size || shortest_path.size == 0
@@ -16,9 +16,13 @@ module Related
16
16
  @end_node ||= Related::Node.find(end_node_id)
17
17
  end
18
18
 
19
- def self.create(type, node1, node2, attributes = {})
19
+ def rank
20
+ Related.redis.zrank("#{self.start_node_id}:rel:#{self.label}:out", self.id)
21
+ end
22
+
23
+ def self.create(label, node1, node2, attributes = {})
20
24
  self.new(attributes.merge(
21
- :type => type,
25
+ :label => label,
22
26
  :start_node_id => node1.to_s,
23
27
  :end_node_id => node2.to_s
24
28
  )).save
@@ -29,22 +33,23 @@ module Related
29
33
  def create
30
34
  Related.redis.multi do
31
35
  super
32
- Related.redis.sadd("#{self.start_node_id}:rel:#{type}:out", self.id)
33
- Related.redis.sadd("#{self.end_node_id}:rel:#{type}:in", self.id)
36
+ score = Time.now.to_i
37
+ Related.redis.zadd("#{self.start_node_id}:rel:#{self.label}:out", score, self.id)
38
+ Related.redis.zadd("#{self.end_node_id}:rel:#{self.label}:in", score, self.id)
34
39
 
35
- Related.redis.sadd("#{self.start_node_id}:nodes:#{type}:out", self.end_node_id)
36
- Related.redis.sadd("#{self.end_node_id}:nodes:#{type}:in", self.start_node_id)
40
+ Related.redis.sadd("#{self.start_node_id}:nodes:#{self.label}:out", self.end_node_id)
41
+ Related.redis.sadd("#{self.end_node_id}:nodes:#{self.label}:in", self.start_node_id)
37
42
  end
38
43
  self
39
44
  end
40
45
 
41
46
  def delete
42
47
  Related.redis.multi do
43
- Related.redis.srem("#{self.start_node_id}:rel:#{type}:out", self.id)
44
- Related.redis.srem("#{self.end_node_id}:rel:#{type}:in", self.id)
48
+ Related.redis.zrem("#{self.start_node_id}:rel:#{self.label}:out", self.id)
49
+ Related.redis.zrem("#{self.end_node_id}:rel:#{self.label}:in", self.id)
45
50
 
46
- Related.redis.srem("#{self.start_node_id}:nodes:#{type}:out", self.end_node_id)
47
- Related.redis.srem("#{self.end_node_id}:nodes:#{type}:in", self.start_node_id)
51
+ Related.redis.srem("#{self.start_node_id}:nodes:#{self.label}:out", self.end_node_id)
52
+ Related.redis.srem("#{self.end_node_id}:nodes:#{self.label}:in", self.start_node_id)
48
53
  super
49
54
  end
50
55
  self
@@ -1,3 +1,3 @@
1
1
  module Related
2
- Version = VERSION = '0.1'
2
+ Version = VERSION = '0.2'
3
3
  end
@@ -0,0 +1,43 @@
1
+ require File.expand_path('test/test_helper')
2
+ require 'related/follower'
3
+
4
+ class User < Related::Node
5
+ include Related::Follower
6
+ end
7
+
8
+ class FollowerTest < Test::Unit::TestCase
9
+
10
+ def setup
11
+ Related.redis.flushall
12
+ @user1 = User.create
13
+ @user2 = User.create
14
+ end
15
+
16
+ def test_can_follow
17
+ @user1.follow!(@user2)
18
+ assert @user1.following?(@user2)
19
+ assert @user2.followed_by?(@user1)
20
+ end
21
+
22
+ def test_can_unfollow
23
+ @user1.follow!(@user2)
24
+ @user1.unfollow!(@user2)
25
+ assert_equal false, @user1.following?(@user2)
26
+ end
27
+
28
+ def test_can_count_followers_and_following
29
+ @user1.follow!(@user2)
30
+ assert_equal 1, @user1.following_count
31
+ assert_equal 0, @user1.followers_count
32
+ assert_equal 0, @user2.following_count
33
+ assert_equal 1, @user2.followers_count
34
+ end
35
+
36
+ def test_can_compute_friends
37
+ @user1.follow!(@user2)
38
+ @user2.follow!(@user1)
39
+ assert_equal [@user2], @user1.friends.to_a
40
+ assert_equal [@user1], @user2.friends.to_a
41
+ end
42
+
43
+ end
data/test/related_test.rb CHANGED
@@ -131,7 +131,29 @@ class RelatedTest < Test::Unit::TestCase
131
131
  Related::Relationship.create(:friends, node1, node3)
132
132
  Related::Relationship.create(:friends, node1, node4)
133
133
  Related::Relationship.create(:friends, node1, node5)
134
- assert_equal 3, node1.outgoing(:friends).limit(3).to_a.size
134
+ assert_equal 3, node1.outgoing(:friends).nodes.limit(3).to_a.size
135
+ assert_equal 3, node1.outgoing(:friends).relationships.limit(3).to_a.size
136
+ end
137
+
138
+ def test_can_paginate_the_results_from_a_query
139
+ node1 = Related::Node.create
140
+ node2 = Related::Node.create
141
+ node3 = Related::Node.create
142
+ node4 = Related::Node.create
143
+ node5 = Related::Node.create
144
+ rel1 = Related::Relationship.create(:friends, node1, node2)
145
+ sleep(1)
146
+ rel2 = Related::Relationship.create(:friends, node1, node3)
147
+ sleep(1)
148
+ rel3 = Related::Relationship.create(:friends, node1, node4)
149
+ sleep(1)
150
+ rel4 = Related::Relationship.create(:friends, node1, node5)
151
+ sleep(1)
152
+ rel5 = Related::Relationship.create(:friends, node1, node5)
153
+ assert_equal [rel1,rel2,rel3], node1.outgoing(:friends).relationships.per_page(3).page(1).to_a
154
+ assert_equal [rel4,rel5], node1.outgoing(:friends).relationships.per_page(3).page(2).to_a
155
+ assert_equal [rel2,rel3,rel4], node1.outgoing(:friends).relationships.per_page(3).page(rel1).to_a
156
+ assert_equal [rel4,rel5], node1.outgoing(:friends).relationships.per_page(3).page(rel3).to_a
135
157
  end
136
158
 
137
159
  def test_can_count_the_number_of_related_nodes
@@ -144,10 +166,14 @@ class RelatedTest < Test::Unit::TestCase
144
166
  rel1 = Related::Relationship.create(:friends, node1, node3)
145
167
  rel1 = Related::Relationship.create(:friends, node1, node4)
146
168
  rel1 = Related::Relationship.create(:friends, node1, node5)
147
- assert_equal 4, node1.outgoing(:friends).count
148
- assert_equal 4, node1.outgoing(:friends).size
149
- assert_equal 3, node1.outgoing(:friends).limit(3).count
150
- assert_equal 4, node1.outgoing(:friends).limit(5).count
169
+ assert_equal 4, node1.outgoing(:friends).nodes.count
170
+ assert_equal 4, node1.outgoing(:friends).nodes.size
171
+ assert_equal 3, node1.outgoing(:friends).nodes.limit(3).count
172
+ assert_equal 4, node1.outgoing(:friends).nodes.limit(5).count
173
+ assert_equal 4, node1.outgoing(:friends).relationships.count
174
+ assert_equal 4, node1.outgoing(:friends).relationships.size
175
+ assert_equal 3, node1.outgoing(:friends).relationships.limit(3).count
176
+ assert_equal 4, node1.outgoing(:friends).relationships.limit(5).count
151
177
  end
152
178
 
153
179
  def test_can_find_path_between_two_nodes
@@ -200,4 +226,43 @@ class RelatedTest < Test::Unit::TestCase
200
226
  assert_equal [node1,node2,node5,node8], node1.shortest_path_to(node8).outgoing(:friends).depth(5).include_start_node.to_a
201
227
  end
202
228
 
229
+ def test_can_union
230
+ node1 = Related::Node.create
231
+ node2 = Related::Node.create
232
+ node3 = Related::Node.create
233
+ node4 = Related::Node.create
234
+ Related::Relationship.create(:friends, node1, node3)
235
+ Related::Relationship.create(:friends, node2, node4)
236
+ Related::Relationship.create(:friends, node2, node3)
237
+ nodes = node1.outgoing(:friends).union(node2.outgoing(:friends)).to_a
238
+ assert_equal 2, nodes.size
239
+ assert nodes.include?(node3)
240
+ assert nodes.include?(node4)
241
+ end
242
+
243
+ def test_can_diff
244
+ node1 = Related::Node.create
245
+ node2 = Related::Node.create
246
+ node3 = Related::Node.create
247
+ node4 = Related::Node.create
248
+ Related::Relationship.create(:friends, node1, node3)
249
+ Related::Relationship.create(:friends, node2, node4)
250
+ Related::Relationship.create(:friends, node2, node3)
251
+ nodes = node2.outgoing(:friends).diff(node1.outgoing(:friends)).to_a
252
+ assert_equal 1, nodes.size
253
+ assert nodes.include?(node4)
254
+ end
255
+
256
+ def test_can_intersect
257
+ node1 = Related::Node.create
258
+ node2 = Related::Node.create
259
+ Related::Relationship.create(:friends, node1, node2)
260
+ Related::Relationship.create(:friends, node2, node1)
261
+ assert_equal [node2], node1.outgoing(:friends).intersect(node1.incoming(:friends)).to_a
262
+ assert_equal [node1], node2.outgoing(:friends).intersect(node2.incoming(:friends)).to_a
263
+ node3 = Related::Node.create
264
+ Related::Relationship.create(:friends, node1, node3)
265
+ assert_equal [node1], node2.incoming(:friends).intersect(node3.incoming(:friends)).to_a
266
+ end
267
+
203
268
  end
data/test/test_helper.rb CHANGED
@@ -2,6 +2,7 @@
2
2
  dir = File.dirname(File.expand_path(__FILE__))
3
3
  $LOAD_PATH.unshift dir + '/../lib'
4
4
 
5
+ require 'rubygems'
5
6
  require 'test/unit'
6
7
  require 'related'
7
8
 
metadata CHANGED
@@ -1,11 +1,12 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: related
3
3
  version: !ruby/object:Gem::Version
4
+ hash: 15
4
5
  prerelease: false
5
6
  segments:
6
7
  - 0
7
- - 1
8
- version: "0.1"
8
+ - 2
9
+ version: "0.2"
9
10
  platform: ruby
10
11
  authors:
11
12
  - Niklas Holmgren
@@ -13,7 +14,7 @@ autorequire:
13
14
  bindir: bin
14
15
  cert_chain: []
15
16
 
16
- date: 2011-09-09 00:00:00 +02:00
17
+ date: 2011-09-14 00:00:00 +02:00
17
18
  default_executable:
18
19
  dependencies:
19
20
  - !ruby/object:Gem::Dependency
@@ -24,6 +25,7 @@ dependencies:
24
25
  requirements:
25
26
  - - ">"
26
27
  - !ruby/object:Gem::Version
28
+ hash: 15
27
29
  segments:
28
30
  - 2
29
31
  - 0
@@ -39,6 +41,7 @@ dependencies:
39
41
  requirements:
40
42
  - - ">"
41
43
  - !ruby/object:Gem::Version
44
+ hash: 63
42
45
  segments:
43
46
  - 0
44
47
  - 8
@@ -62,11 +65,13 @@ files:
62
65
  - CHANGELOG
63
66
  - lib/related/entity.rb
64
67
  - lib/related/exceptions.rb
68
+ - lib/related/follower.rb
65
69
  - lib/related/helpers.rb
66
70
  - lib/related/node.rb
67
71
  - lib/related/relationship.rb
68
72
  - lib/related/version.rb
69
73
  - lib/related.rb
74
+ - test/follower_test.rb
70
75
  - test/performance_test.rb
71
76
  - test/redis-test.conf
72
77
  - test/related_test.rb
@@ -85,6 +90,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
85
90
  requirements:
86
91
  - - ">="
87
92
  - !ruby/object:Gem::Version
93
+ hash: 3
88
94
  segments:
89
95
  - 0
90
96
  version: "0"
@@ -93,6 +99,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
93
99
  requirements:
94
100
  - - ">="
95
101
  - !ruby/object:Gem::Version
102
+ hash: 3
96
103
  segments:
97
104
  - 0
98
105
  version: "0"