related 0.1 → 0.2

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 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"