opinions 0.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.
@@ -0,0 +1,18 @@
1
+ require 'autotest/restart'
2
+
3
+ class Autotest
4
+
5
+ # def get_to_green
6
+ # begin
7
+ # rerun_all_tests
8
+ # wait_for_changes unless all_good
9
+ # end until all_good
10
+ # end
11
+
12
+ end
13
+
14
+ Autotest.add_hook :initialize do |at|
15
+ at.add_mapping(/^test.*\/.*_test_.*rb$/) do |filename, _|
16
+ filename
17
+ end
18
+ end
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in emotions.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2012 Lee Hambley
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,183 @@
1
+ # Opinions
2
+
3
+ **Store opinions in Redis.** *Opinions* allows the storage of opinions in
4
+ Redis, a fast and atomic structured data store. If one's users *hate*, *love*,
5
+ *appreciate*, *despise* or *just-don-t-care*, one can store that easily via a
6
+ simple API.
7
+
8
+ It is not bound to *ActiveRecord*, or any other big libraries, any class exposing a
9
+ public `id` method can be used. The `id` method is not required to be
10
+ numerical.
11
+
12
+ ``` ruby
13
+ class CatPicture < ActiveRecord::Base
14
+
15
+ include Opinions::Pollable
16
+ opinions :like, :dislike
17
+
18
+ end
19
+ ```
20
+
21
+ This simple example shows that in our logical model cat pictures can either be
22
+ liked, or disliked. The following methods are available to all instances of `CatPicture`:
23
+
24
+ * `like_by(...)`
25
+ * `cancel_like_by(...)`
26
+ * `like_votes()`
27
+ * `dialike_by(...)`
28
+ * `cancel_dislike_by(...)`
29
+ * `dislike_votes()`
30
+
31
+ On the flip-side, one needs a way to share one's feelings, from the model representing
32
+ a user, or rater, or similar, one can easily use the opposite:
33
+
34
+ ``` ruby
35
+ class User < ActiveRecord::Base
36
+
37
+ include Opinions::Opinionated
38
+ opinions :like, :dislike
39
+
40
+ end
41
+ ```
42
+
43
+ This module will mix-into the `User` the following methods:
44
+
45
+ * `like(...)`
46
+ * `dislike(...)`
47
+ * `cancel_like(...)`
48
+ * `cancel_dislike(...)`
49
+ * `like_opinions()`
50
+ * `dislike_opinions()`
51
+ * `have_like_on(...)`
52
+ * `have_dislike_on(...)`
53
+
54
+ These methods can be passed instances of any class which has those opinions defined.
55
+
56
+ It should be absolutely trivial to extend these to any behaviour you
57
+ need.
58
+
59
+ **Note:** It is by design that these methods do not read particularly
60
+ naturally, you are invited to read the source, and tests of the
61
+ Pollable, and Opinionated modules and implement them in a way which
62
+ better reflects the grammar of your application, and desired *Opinions*.
63
+ Think of these modules as examples, something to expand upon.
64
+
65
+ ## Inspiration
66
+
67
+ *Opinions* is inspired by [`schneems/likeable`](https://github.com/schneems/Likeable). A few
68
+ things concerned me about that project, so I wrote *opinions* after contributing
69
+ significant fixes to *likeable*.
70
+
71
+ ### What's different from *Likeable*?
72
+
73
+ * There are no hard-coded assumptions about which opinions you'll be using, that's
74
+ up to your project needs.
75
+
76
+ * There are no callbacks, these are better handled with observers, either in the
77
+ classical OOP meaning of the word, or your framework's pattern. (In Rails they're
78
+ the same thing)
79
+
80
+ * A *very* comprehensive test suite, written with *MiniTest*. *Likeable* is quite
81
+ simple, and has about ~35 tests, that might be OK for you, and Gowalla, but I'd
82
+ feel better with real unit, functional and integration tests.
83
+
84
+ * It's not *totally* bound to Redis. Internally there's a Key/Value store proxy, this
85
+ uses Redis out of the box, but it should be easy for someone to replace this with
86
+ MongoDB, Riak, DynamoDB, SQLite, etc.
87
+
88
+ * It does not depend on *ActiveSupport*, *likeable* depends on *keytar*, which depends
89
+ on `ActiveSupport` for inflection and *ActiveSupport::Concern.*
90
+
91
+ * It does not depend on `Keytar`, Keytar is a handy tool for building NoSQL keys for
92
+ objects, however it's a little bit over-featured for this use-case.
93
+
94
+ * *Likeable* stores timestamps as floating point numbers, I'm confused
95
+ by this. Sub-second resoltion seems unusual here, and isn't easy for a
96
+ human being to read. *Opinions* uses the time format: `%Y-%m-%d %H:%M:%S %z`.
97
+
98
+ * *Likeable* doesn't store *symetrical* relationships, using *Likeable*
99
+ it's only possible to have one type of object sharing opinions on any
100
+ other (*Users*). *Opinions* stores the relationship symetrically, so
101
+ many kinds of objects can store many kinds of opinions.
102
+
103
+ * *Likeable* stores the class name unaltered, this can cause problems
104
+ with namespaced classes as the class namespace separator in Ruby is
105
+ `::`, this conflicts with the sepatator traditionally used in Redis.
106
+ *Opinions* stores the class names processed with an *ActiveSupport*
107
+ inspired `underscore` method which uses the forward slash character to
108
+ represent a namespace delimiter.
109
+
110
+ ## Migrating from *Likeable*
111
+
112
+ Unfortunately the key structure is sufficiently different that you'll
113
+ need to explicitly migrate, there's no shortcut. The key to migrating
114
+ sucessfully is that the `vote(target)` and `vote_by(object)` methods
115
+ take an optional time parameter in the second position. If this is
116
+ passed then it will
117
+
118
+ ## Installation
119
+
120
+ Add this line to your application's Gemfile:
121
+
122
+ gem 'opinions'
123
+
124
+ And then execute:
125
+
126
+ $ bundle
127
+
128
+ Or install it yourself as:
129
+
130
+ $ gem install opinions
131
+
132
+ ## Usage
133
+
134
+ TODO: Write usage instructions here
135
+
136
+ ## Contributing
137
+
138
+ 1. Fork it
139
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
140
+ 3. Commit your changes (`git commit -am 'Added some feature'`)
141
+ 4. Push to the branch (`git push origin my-new-feature`)
142
+ 5. Create new Pull Request
143
+
144
+
145
+ ## Sample Key Structure
146
+
147
+ Given the following example, the key
148
+ structure would be:
149
+
150
+ ``` ruby
151
+ class Recommendation
152
+ iclude Opinions::Pollable
153
+ opinions :like
154
+ end
155
+
156
+ class User
157
+ include Opinions::Opinionated
158
+
159
+ end
160
+
161
+ User.find(123).like(Recommendation.find(789)
162
+ User.find(123).like(Recommendation.find(987)
163
+
164
+ User.find(321).like(Recommendation.find(789)
165
+ ```
166
+
167
+ The resulting Redis structure would be something like this:
168
+
169
+ ``` text
170
+ user:like:123:recommendation
171
+ "789" "2012-11-13 00:01:02 +01:00"
172
+ "987" "2011-02-01 00:03:01 +01:00"
173
+
174
+ user:like:321:recommendation
175
+ "789" "2014-02-01 17:15:01 +01:00"
176
+
177
+ recommendation:like:789:user
178
+ "123" "2012-11-13 00:01:02 +01:00"
179
+ "321" "2014-02-01 17:15:01 +01:00"
180
+
181
+ recommendation:like:987:user
182
+ "123" "2011-02-01 00:03:01 +01:00"
183
+ ```
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+ require 'rake/testtask'
4
+
5
+ namespace :test do
6
+
7
+ Rake::TestTask.new(:units) do |t|
8
+ t.libs << "test"
9
+ t.test_files = FileList['test/unit_test*.rb']
10
+ end
11
+
12
+ Rake::TestTask.new(:acceptance) do |t|
13
+ t.libs << "test"
14
+ t.test_files = FileList['test/acceptance_test*.rb']
15
+ end
16
+
17
+ Rake::TestTask.new(:integration) do |t|
18
+ t.libs << "test"
19
+ t.test_files = FileList['test/integration_test*.rb']
20
+ end
21
+
22
+ task :default do
23
+ Rake::Task['test:units'].execute
24
+ Rake::Task['test:acceptance'].execute
25
+ Rake::Task['test:integration'].execute
26
+ end
27
+
28
+ end
29
+
30
+ task :default => 'test:default'
@@ -0,0 +1,270 @@
1
+ require 'opinions/version'
2
+ require 'singleton'
3
+ require 'redis'
4
+
5
+ module Opinions
6
+
7
+ class << self
8
+ attr_accessor :backend
9
+ end
10
+
11
+ module KeyBuilderExtensions
12
+
13
+ def generate_key(scope, id = nil)
14
+ [self.class.name, scope, id].compact.join(':')
15
+ end
16
+
17
+ end
18
+
19
+ class KeyBuilder
20
+
21
+ def initialize(args)
22
+ @object = args.fetch(:object)
23
+ @target = args.fetch(:target, nil)
24
+ @opinion = args.fetch(:opinion)
25
+ end
26
+
27
+ def key
28
+ object = @object.dup
29
+ object.class.send(:include, KeyBuilderExtensions)
30
+ key = object.generate_key(@opinion, object.id)
31
+ if @target
32
+ tcn = @target.class == Class ? @target.name : @target.class.name
33
+ key += ":#{tcn}"
34
+ end
35
+ key
36
+ end
37
+
38
+ end
39
+
40
+ class OpinionFactory
41
+ attr_reader :key_name
42
+ def initialize(args)
43
+ @direction = args.has_key?(:from_target) ? :ot : :to
44
+ @key_name = args.first[1]
45
+ end
46
+ def opinion
47
+ Opinions.backend.read_key(key_name).collect do |object_id, time|
48
+ target_class_name, opinion, target_id, object_class_name = key_name.split ':'
49
+ target_class, object_class = Kernel.const_get(target_class_name), Kernel.const_get(object_class_name)
50
+ Opinion.new(target: (@direction == :to ? object_class.find(object_id) : target_class.find(target_id)),
51
+ object: (@direction == :to ? target_class.find(target_id) : object_class.find(object_id)),
52
+ opinion: opinion.to_sym,
53
+ created_at: time)
54
+ end
55
+ end
56
+ end
57
+
58
+ class RedisBackend
59
+
60
+ attr_accessor :redis
61
+
62
+ def write_keys(key_hashes)
63
+ redis.multi do
64
+ key_hashes.each do |key_name, hash|
65
+ write_key(key_name, hash)
66
+ end
67
+ end
68
+ end
69
+
70
+ def write_key(key_name, hash)
71
+ hash.each do |hash_key, hash_value|
72
+ redis.hset key_name, hash_key, hash_value
73
+ end
74
+ end
75
+ private :write_key
76
+
77
+ def read_key(key_name)
78
+ redis.hgetall(key_name)
79
+ end
80
+
81
+ def read_sub_key(key_name, key)
82
+ redis.hget(key_name, key)
83
+ end
84
+
85
+ def remove_sub_keys(key_pairs)
86
+ redis.multi do
87
+ key_pairs.each do |key_name, key|
88
+ redis.hdel(key_name, key.to_s)
89
+ end
90
+ end
91
+ end
92
+
93
+ def keys_matching(argument)
94
+ redis.keys(argument)
95
+ end
96
+
97
+ end
98
+
99
+ class KeyLoader
100
+
101
+ def initialize(key)
102
+ @object_class, @opinion, @object_id, @target_class = key.split(':')
103
+ end
104
+
105
+ def object
106
+ Object.const_get(@object_class).find(_object_id)
107
+ end
108
+
109
+ private
110
+
111
+ def _object_id
112
+ @object_id.to_i == @object_id ? @object_id : @object_id.to_i
113
+ end
114
+
115
+ end
116
+
117
+ class Opinion
118
+
119
+ attr_accessor :target, :object, :opinion, :created_at
120
+
121
+ def initialize(args = {})
122
+ @target, @object, @opinion, @created_at =
123
+ args.fetch(:target), args.fetch(:object), args.fetch(:opinion), args.fetch(:created_at, nil)
124
+ self
125
+ end
126
+
127
+ def persist(args = {time: Time.now.utc})
128
+ backend.write_keys({
129
+ target_key => {object.id.to_s => args.fetch(:time)},
130
+ object_key => {target.id.to_s => args.fetch(:time)},
131
+ })
132
+ end
133
+
134
+ def object_key
135
+ KeyBuilder.new(object: object, opinion: opinion, target: target).key
136
+ end
137
+
138
+ def target_key
139
+ KeyBuilder.new(object: target, opinion: opinion, target: object).key
140
+ end
141
+
142
+ def exists?
143
+ tk = backend.read_sub_key(target_key, object.id.to_s)
144
+ ok = backend.read_sub_key(object_key, target.id.to_s)
145
+ tk && ok
146
+ end
147
+
148
+ def remove
149
+ backend.remove_sub_keys([[target_key, object.id.to_s],
150
+ [object_key, target.id.to_s]])
151
+ end
152
+
153
+ def ==(other_opinion)
154
+ raise ArgumentError, "Can't compare a #{other_opinion} with #{self}" unless other_opinion.is_a?(Opinion)
155
+ opinion_equal = self.opinion == other_opinion.opinion
156
+ opinion_target = self.target == other_opinion.target
157
+ opinion_object = self.object == other_opinion.object
158
+ opinion_equal && opinion_target && opinion_object
159
+ end
160
+
161
+ private
162
+
163
+ def backend
164
+ Opinions.backend
165
+ end
166
+
167
+ end
168
+
169
+ module Pollable
170
+
171
+ class << self
172
+
173
+ def included(klass)
174
+ klass.send(:include, InstanceMethods)
175
+ klass.send(:extend, ClassMethods)
176
+ end
177
+
178
+ end
179
+
180
+ module ClassMethods
181
+ def opinions(*opinions)
182
+ opinions.each { |opinion| register_opinion(opinion.to_sym) }
183
+ end
184
+ def register_opinion(name)
185
+ @registered_opinions ||= Array.new
186
+ @registered_opinions << name
187
+ end
188
+ def registered_opinions
189
+ @registered_opinions
190
+ end
191
+ end
192
+
193
+ module InstanceMethods
194
+
195
+ def initialize(*args)
196
+ super
197
+ self.class.registered_opinions.each do |opinion|
198
+ self.class.send :define_method, :"#{opinion}_by" do |*args|
199
+ opinionated, time = *args
200
+ time = time || Time.now.utc
201
+ e = Opinion.new(object: opinionated, target: self, opinion: opinion)
202
+ true & e.persist(time: time)
203
+ end
204
+ self.class.send :define_method, :"cancel_#{opinion}_by" do |opinionated|
205
+ true & Opinion.new(object: opinionated, target: self, opinion: opinion).remove
206
+ end
207
+ self.class.send :define_method, :"#{opinion}_votes" do
208
+ lookup_key_builder = KeyBuilder.new(object: self, opinion: opinion)
209
+ keys = Opinions.backend.keys_matching(lookup_key_builder.key + "*")
210
+ keys.collect do |key_name|
211
+ OpinionFactory.new(from_target: key_name).opinion
212
+ end.flatten
213
+ end
214
+ end
215
+ end
216
+
217
+ end
218
+
219
+ end
220
+
221
+ module Opinionated
222
+
223
+ def self.included(klass)
224
+ klass.send(:include, InstanceMethods)
225
+ klass.send(:extend, ClassMethods)
226
+ end
227
+
228
+ module ClassMethods
229
+ def opinions(*opinions)
230
+ opinions.each { |opinion| register_opinion(opinion.to_sym) }
231
+ end
232
+ def register_opinion(name)
233
+ @registered_opinions ||= Array.new
234
+ @registered_opinions << name
235
+ end
236
+ def registered_opinions
237
+ @registered_opinions
238
+ end
239
+ end
240
+
241
+ module InstanceMethods
242
+ def initialize(*args)
243
+ super
244
+ self.class.registered_opinions.each do |opinion|
245
+ self.class.send :define_method, :"#{opinion}" do |*args|
246
+ target, time = *args
247
+ time = time || Time.now.utc
248
+ e = Opinion.new(object: self, target: target, opinion: opinion)
249
+ true & e.persist(time: time)
250
+ end
251
+ self.class.send :define_method, :"cancel_#{opinion}" do |pollable|
252
+ true & Opinion.new(object: self, target: pollable, opinion: opinion).remove
253
+ end
254
+ self.class.send :define_method, :"have_#{opinion}_on" do |pollable|
255
+ send("#{opinion}_opinions").collect { |o| o.target == pollable }.any?
256
+ end
257
+ self.class.send :define_method, :"#{opinion}_opinions" do
258
+ lookup_key_builder = KeyBuilder.new(object: self, opinion: opinion)
259
+ keys = Opinions.backend.keys_matching(lookup_key_builder.key + "*")
260
+ keys.collect do |key_name|
261
+ OpinionFactory.new(from_object: key_name).opinion
262
+ end.flatten
263
+ end
264
+ end
265
+ end
266
+ end
267
+
268
+ end
269
+
270
+ end