Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

First commit

Connects to Redis, constructs sets, and intersects them
  • Loading branch information...
commit 99ca2638dcb03c8689b240058e93102a2a243c2c 0 parents
Chase Stubblefield chasetopher authored
2  .gitignore
... ... @@ -0,0 +1,2 @@
  1 +.bin
  2 +Gemfile.lock
1  .rbenv-version
... ... @@ -0,0 +1 @@
  1 +1.9.3-p0
10 Gemfile
... ... @@ -0,0 +1,10 @@
  1 +source "http://rubygems.org"
  2 +
  3 +gemspec
  4 +
  5 +gem "ruby-debug", :platform => :ruby_18
  6 +# Needed for using ruby-debug on 1.9
  7 +gem "ruby-debug19", :platform => :ruby_19
  8 +# Needed for ruby-debug when running on 1.9.3
  9 +gem 'linecache19', :git => 'git://github.com/mark-moseley/linecache', :platform => :ruby_19
  10 +gem 'ruby-debug-base19x', '~> 0.11.30.pre4', :platform => :ruby_19
22 lib/list_master.rb
... ... @@ -0,0 +1,22 @@
  1 +require 'redis'
  2 +require 'redis-namespace'
  3 +
  4 +module ListMaster
  5 +
  6 + extend self
  7 +
  8 + # Accepts a Redis object
  9 + def redis=(redis)
  10 + @redis = Redis::Namespace.new :list_master, :redis => redis
  11 + end
  12 +
  13 + # Returns the current Redis connection. If none has been created, create default
  14 + def redis
  15 + return @redis if @redis
  16 + self.redis = Redis.connect
  17 + self.redis
  18 + end
  19 +
  20 +end
  21 +
  22 +require 'list_master/base'
115 lib/list_master/base.rb
... ... @@ -0,0 +1,115 @@
  1 +# ListMaster::Base
  2 +#
  3 +# This is a base class for an object that maintains redis zsets for an ActiveRecord model.
  4 +# A list of sets are defined in a mini DSL.
  5 +#
  6 +# For example, let's say you have a model Item with attributes 'power_level and 'category'.
  7 +# You would define the ListMaster for this as follows:
  8 +#
  9 +# class ItemListMaster < ListMaster::Base
  10 +# model 'Item'
  11 +#
  12 +# set 'power', :attribute => 'power_level'
  13 +# set 'category'
  14 +# end
  15 +#
  16 +# Now when ItemListMaster.new.process is called, the following sorted sets will be put in redis:
  17 +#
  18 +# power (items scored by there 'power_level' attribute)
  19 +# category:category_one (all items have zero score)
  20 +# category:category_two
  21 +# category:category_three (...and so on for every value of 'category')
  22 +#
  23 +# These sorted sets simply hold ids to the objects that they represent collections of.
  24 +#
  25 +# You can then ask for arrays of ids for items that are in multiple sets:
  26 +#
  27 +# a = ItemListMaster.new
  28 +# a.process
  29 +# a.intersect 'power', 'category:category_one' #=> Array of ids of Items in 'category_one' ordered by 'power_level'
  30 +
  31 +module ListMaster
  32 + class Base
  33 +
  34 + class << self
  35 + #
  36 + # Associating this list master with a model
  37 + #
  38 + def model model_name
  39 + @@model = model_name.constantize
  40 + end
  41 +
  42 + #
  43 + # Defining sets to maintain
  44 + #
  45 + @@sets = []
  46 + def set *args
  47 + options = {
  48 + :attribute => nil,
  49 + :descending => nil
  50 + }.merge(args.extract_options!)
  51 + name = args.first
  52 + @@sets << {
  53 + name: name,
  54 + score: options[:attribute] || nil,
  55 + descending: options[:descending] || nil
  56 + }
  57 + end
  58 + end
  59 +
  60 + #
  61 + # This instance's redis namespace
  62 + #
  63 + def redis
  64 + @redis ||= Redis::Namespace.new self.class.name.underscore, :redis => ListMaster.redis
  65 + end
  66 +
  67 + #
  68 + # Goes through every record of the model and adds the id to every relevant set
  69 + #
  70 + def process
  71 + @@model.find_each do |obj|
  72 + @@sets.each do |set|
  73 + if set[:score]
  74 + score = obj.read_attribute(set[:score]).to_score
  75 + score *= -1 if set[:descending]
  76 + redis.zadd set[:name], score, obj.id
  77 + else
  78 + attribute = obj.read_attribute(set[:name]) # used for name of set
  79 + redis.zadd "#{set[:name]}:#{attribute}", 0, obj.id
  80 + end
  81 + end
  82 + end
  83 + end
  84 +
  85 + #
  86 + # Takes a sequence of list names to intersect
  87 + # Also has options :limit (default 10) and :offset (default 0)
  88 + #
  89 + # Returns an Array of integer ids
  90 + #
  91 + def intersect *args
  92 + options = args.extract_options!
  93 + limit = options[:limit] || 10
  94 + offset = options[:offset] || 0
  95 +
  96 + args = args.map { |a| 'list_master:item_list_master:' + a }
  97 +
  98 + redis.zinterstore 'list_master:item_list_master:out', args if args.count > 1
  99 +
  100 + redis.zrange('out', offset, offset + limit).map(&:to_i)
  101 + end
  102 + end
  103 +end
  104 +
  105 +class Object
  106 + def to_score
  107 + if respond_to? :to_i
  108 + to_i
  109 + elsif is_a? Date
  110 + to_time.to_i
  111 + else
  112 + raise 'unable to convert #{self.inspect} to a zset score'
  113 + end
  114 + end
  115 +end
3  lib/list_master/version.rb
... ... @@ -0,0 +1,3 @@
  1 +module ListMaster
  2 + VERSION = '0.0.1'
  3 +end
27 list_master.gemspec
... ... @@ -0,0 +1,27 @@
  1 +lib = File.expand_path('../lib/', __FILE__)
  2 +$:.unshift lib unless $:.include?(lib)
  3 +
  4 +require 'list_master/version'
  5 +
  6 +Gem::Specification.new do |s|
  7 + s.name = "list_master"
  8 + s.version = ListMaster::VERSION
  9 + s.platform = Gem::Platform::RUBY
  10 + s.authors = ["The I18n Team <3"]
  11 + s.email = ["tech_ops@change.org"]
  12 + s.homepage = "http://github.com/change/list_master"
  13 + s.summary = %q{A redis solution for presenting paginated, scoped lists of models}
  14 + s.description = %q{It is not finished}
  15 +
  16 + s.files = Dir.glob("{bin,lib}/**/*") + %w(LICENSE README.md)
  17 + s.require_path = 'lib'
  18 +
  19 + s.required_rubygems_version = ">= 1.3.6"
  20 +
  21 + s.add_dependency "rails", "~> 3.0.0"
  22 + s.add_dependency "redis"
  23 + s.add_dependency "redis-namespace"
  24 +
  25 + s.add_development_dependency "rspec-rails"
  26 + s.add_development_dependency "sqlite3"
  27 +end
30 spec/fixtures.rb
... ... @@ -0,0 +1,30 @@
  1 +#
  2 +# Create db and table
  3 +#
  4 +
  5 +TEST_DB = 'tmp/testdb.sqlite3'
  6 +FileUtils.rm_f TEST_DB
  7 +
  8 +SQLite3::Database.new(TEST_DB) do |db| db.execute_batch <<-SQL
  9 + CREATE TABLE items (
  10 + id INTEGER PRIMARY KEY,
  11 + name TEXT,
  12 + category TEXT,
  13 + created_at DATE,
  14 + updated_at DATE
  15 + );
  16 + SQL
  17 +end
  18 +
  19 +#
  20 +# An example model
  21 +#
  22 +
  23 +ActiveRecord::Base.establish_connection adapter: 'sqlite3', database: TEST_DB
  24 +
  25 +class Item < ActiveRecord::Base
  26 +end
  27 +
  28 +Item.create! name: 'foo', category: 'a', :created_at => 2.days.ago # id: 1
  29 +Item.create! name: 'bar', category: 'b', :created_at => 1.days.ago # id: 2
  30 +Item.create! name: 'baz', category: 'b', :created_at => 30.seconds.ago # id: 3
49 spec/item_list_master_spec.rb
... ... @@ -0,0 +1,49 @@
  1 +require 'spec_helper'
  2 +
  3 +class ItemListMaster < ListMaster::Base
  4 + model 'Item'
  5 +
  6 + set 'recent', :attribute => 'created_at', :descending => true
  7 + set 'category'
  8 +end
  9 +
  10 +describe ItemListMaster do
  11 +
  12 + before do
  13 + @master = ItemListMaster.new
  14 + end
  15 +
  16 + describe "#process" do
  17 +
  18 + before do
  19 + @master.process
  20 + end
  21 +
  22 + it 'should generate a zero priority zset for every attribute value for every declared set without priorty' do
  23 + @master.redis.type('category:a').should == 'zset'
  24 + @master.redis.type('category:b').should == 'zset'
  25 + @master.redis.zrange('category:b', 0, -1).map(&:to_i).to_set.should == Set.new([2, 3])
  26 + @master.redis.zscore('category:b', 2).to_i.should == 0
  27 + @master.redis.zscore('category:b', 3).to_i.should == 0
  28 + end
  29 +
  30 + it 'should generate a zset for every declared set with priority' do
  31 + @master.redis.type('recent').should == 'zset'
  32 + @master.redis.zrange('recent', 0, -1).map(&:to_i).should == [3, 2, 1]
  33 + end
  34 +
  35 + describe "#intersect" do
  36 +
  37 + it 'should return an array of ids that are in both lists' do
  38 + @master.intersect('recent', 'category:b').should == [3, 2]
  39 + end
  40 +
  41 + it 'should accept limit and offset' do
  42 + @master.intersect('recent', 'category:b', :limit => 1, :offset => 1).should == [2]
  43 + end
  44 +
  45 + end
  46 +
  47 + end
  48 +
  49 +end
17 spec/list_master_spec.rb
... ... @@ -0,0 +1,17 @@
  1 +require 'spec_helper'
  2 +
  3 +describe ListMaster do
  4 +
  5 + describe "#redis" do
  6 +
  7 + it 'should return a redis namespace' do
  8 + ListMaster.redis.class.should == Redis::Namespace
  9 + ListMaster.redis.ping.should be_present
  10 +
  11 + ListMaster.redis.set 'foo', 'bar'
  12 + ListMaster.redis.get('foo').should be_eql 'bar'
  13 + end
  14 +
  15 + end
  16 +
  17 +end
20 spec/spec_helper.rb
... ... @@ -0,0 +1,20 @@
  1 +require 'rails/all'
  2 +require 'rspec-rails'
  3 +require 'sqlite3'
  4 +
  5 +require 'list_master'
  6 +
  7 +require 'fixtures'
  8 +
  9 +ListMaster.redis = Redis.connect :db => 9
  10 +ListMaster.redis.flushdb
  11 +
  12 +RSpec.configure do |config|
  13 + config.after(:each) do
  14 + ListMaster.redis.flushdb
  15 + end
  16 +
  17 + config.after(:suite) do
  18 + FileUtils.rm TEST_DB
  19 + end
  20 +end
2  tmp/.gitignore
... ... @@ -0,0 +1,2 @@
  1 +*
  2 +!.gitignore

0 comments on commit 99ca263

Please sign in to comment.
Something went wrong with that request. Please try again.