Permalink
Browse files

Update documentation from old `bitmask_field` plugin, move things aro…

…und.
  • Loading branch information...
1 parent 63c4156 commit ded0bdaf3ca904c0487fa306bdc99d09b1cd8ebc @bruce bruce committed May 19, 2009
Showing with 371 additions and 12 deletions.
  1. +1 −1 .document
  2. +1 −1 LICENSE
  3. +47 −0 README.markdown
  4. +0 −7 README.rdoc
  5. +2 −0 lib/bitmask-attribute.rb
  6. +100 −0 lib/bitmask_attribute.rb
  7. +72 −0 lib/bitmask_attribute/value_proxy.rb
  8. +3 −0 rails/init.rb
  9. +99 −2 test/bitmask_attribute_test.rb
  10. +46 −1 test/test_helper.rb
View
2 .document
@@ -1,4 +1,4 @@
-README.rdoc
+README.markdown
lib/**/*.rb
bin/*
features/**/*.feature
View
2 LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2009 Bruce Williams
+Copyright (c) 2007-2009 Bruce Williams
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
View
47 README.markdown
@@ -0,0 +1,47 @@
+# bitmask-attribute
+
+Transparent manipulation of bitmask attributes.
+
+## Example
+
+Simply declare an existing integer column as a bitmask with its possible
+values.
+
+ class User < ActiveRecord::Base
+ bitmask :roles, :as => [:writer, :publisher, :editor]
+ end
+
+You can then modify the column using the declared values without resorting
+to manual bitmasks.
+
+ user = User.create(:name => "Bruce", :roles => [:publisher, :editor])
+ user.roles
+ # => [:publisher, :editor]
+ user.roles << :writer
+ user.roles
+ # => [:publisher, :editor, :writer]
+
+For the moment, querying for bitmasks is left as an exercise to the reader,
+but here's how to grab the bitmask for a specific possible value for use in
+your SQL query:
+
+ bitmask = User.bitmasks[:roles][:editor]
+ # Use `bitmask` as needed
+
+## Modifying possible values
+
+Once you have data using a bitmask, don't change the order of the values,
+remove any values, or insert any new values in the array anywhere except at
+the end.
+
+## Contributing and reporting issues
+
+Please feel free to fork & contribute fixes via GitHub pull requests.
+The official repository for this project is
+http://github.com/bruce/bitmask-attribute
+
+Issues can be reported at http://github.com/bruce/bitmask-attribute/issues
+
+## Copyright
+
+Copyright (c) 2007-2009 Bruce Williams. See LICENSE for details.
View
7 README.rdoc
@@ -1,7 +0,0 @@
-= bitmask-attribute
-
-Description goes here.
-
-== Copyright
-
-Copyright (c) 2009 Bruce Williams. See LICENSE for details.
View
2 lib/bitmask-attribute.rb
@@ -0,0 +1,2 @@
+# Stub for dash-style requires
+require File.dirname(__FILE__) << "/bitmask_attribute"
View
100 lib/bitmask_attribute.rb
@@ -0,0 +1,100 @@
+require 'bitmask_attribute/value_proxy'
+
+module BitmaskAttribute
+
+ class Definition
+
+ attr_reader :attribute, :values, :extension
+ def initialize(attribute, values=[], &extension)
+ @attribute = attribute
+ @values = values
+ @extension = extension
+ end
+
+ def install_on(model)
+ validate_for model
+ generate_bitmasks_on model
+ override model
+ create_convenience_method_on model
+ end
+
+ #######
+ private
+ #######
+
+ def validate_for(model)
+ unless model.columns.detect { |col| col.name == attribute.to_s && col.type == :integer }
+ raise ArgumentError, "`#{attribute}' is not an integer column of `#{model}'"
+ end
+ end
+
+ def generate_bitmasks_on(model)
+ model.bitmasks[attribute] = returning HashWithIndifferentAccess.new do |mapping|
+ values.each_with_index do |value, index|
+ mapping[value] = 0b1 << index
+ end
+ end
+ end
+
+ def override(model)
+ override_getter_on(model)
+ override_setter_on(model)
+ end
+
+ def override_getter_on(model)
+ model.class_eval(<<-EVAL)
+ def #{attribute}
+ @#{attribute} ||= BitmaskAttribute::ValueProxy.new(self, :#{attribute}, &self.class.bitmask_definitions[:#{attribute}].extension)
+ end
+ EVAL
+ end
+
+ def override_setter_on(model)
+ model.class_eval(<<-EVAL)
+ def #{attribute}=(raw_value)
+ values = raw_value.kind_of?(Array) ? raw_value : [raw_value]
+ #{attribute}.replace(values)
+ end
+ EVAL
+ end
+
+ def create_convenience_method_on(model)
+ model.class_eval(<<-EVAL)
+ def self.bitmask_for_#{attribute}(*values)
+ values.inject(0) do |bitmask, value|
+ unless (bit = bitmasks[:#{attribute}][value])
+ raise ArgumentError, "Unsupported value for #{attribute}: \#{value.inspect}"
+ end
+ bitmask | bit
+ end
+ end
+ EVAL
+ end
+
+ end
+
+ def self.included(model)
+ model.extend ClassMethods
+ end
+
+ module ClassMethods
+
+ def bitmask(attribute, options={}, &extension)
+ unless options[:as] && options[:as].kind_of?(Array)
+ raise ArgumentError, "Must provide an Array :as option"
+ end
+ bitmask_definitions[attribute] = BitmaskAttribute::Definition.new(attribute, options[:as].to_a, &extension)
+ bitmask_definitions[attribute].install_on(self)
+ end
+
+ def bitmask_definitions
+ @bitmask_definitions ||= {}
+ end
+
+ def bitmasks
+ @bitmasks ||= {}
+ end
+
+ end
+
+end
View
72 lib/bitmask_attribute/value_proxy.rb
@@ -0,0 +1,72 @@
+module BitmaskAttribute
+
+ class ValueProxy < Array
+
+ def initialize(record, attribute, &extension)
+ @record = record
+ @attribute = attribute
+ find_mapping
+ instance_eval(&extension) if extension
+ super(extract_values)
+ end
+
+ # =========================
+ # = OVERRIDE TO SERIALIZE =
+ # =========================
+
+ %w(push << delete replace reject! select!).each do |override|
+ class_eval(<<-EOEVAL)
+ def #{override}(*args)
+ returning(super) do
+ updated!
+ end
+ end
+ EOEVAL
+ end
+
+ def to_i
+ inject(0) { |memo, value| memo | @mapping[value] }
+ end
+
+ #######
+ private
+ #######
+
+ def validate!
+ each do |value|
+ if @mapping.key? value
+ true
+ else
+ raise ArgumentError, "Unsupported value for `#{@attribute}': #{value.inspect}"
+ end
+ end
+ end
+
+ def updated!
+ validate!
+ uniq!
+ serialize!
+ end
+
+ def serialize!
+ @record.send(:write_attribute, @attribute, to_i)
+ end
+
+ def extract_values
+ stored = @record.send(:read_attribute, @attribute) || 0
+ @mapping.inject([]) do |values, (value, bitmask)|
+ returning values do
+ values << value.to_sym if (stored & bitmask > 0)
+ end
+ end
+ end
+
+ def find_mapping
+ unless (@mapping = @record.class.bitmasks[@attribute])
+ raise ArgumentError, "Could not find mapping for bitmask attribute :#{@attribute}"
+ end
+ end
+
+ end
+
+end
View
3 rails/init.rb
@@ -0,0 +1,3 @@
+ActiveRecord::Base.instance_eval do
+ include BitmaskAttribute
+end
View
101 test/bitmask_attribute_test.rb
@@ -1,7 +1,104 @@
require 'test_helper'
class BitmaskAttributeTest < Test::Unit::TestCase
- should "probably rename this file and start testing for real" do
- flunk "hey buddy, you should probably rename this file and start testing for real"
+
+ context "Campaign" do
+
+ should "can assign single value to bitmask" do
+ assert_stored Campaign.new(:medium => :web), :web
+ end
+
+ should "can assign multiple values to bitmask" do
+ assert_stored Campaign.new(:medium => [:web, :print]), :web, :print
+ end
+
+ should "can add single value to bitmask" do
+ campaign = Campaign.new(:medium => [:web, :print])
+ assert_stored campaign, :web, :print
+ campaign.medium << :phone
+ assert_stored campaign, :web, :print, :phone
+ end
+
+ should "ignores duplicate values added to bitmask" do
+ campaign = Campaign.new(:medium => [:web, :print])
+ assert_stored campaign, :web, :print
+ campaign.medium << :phone
+ assert_stored campaign, :web, :print, :phone
+ campaign.medium << :phone
+ assert_stored campaign, :web, :print, :phone
+ assert_equal 1, campaign.medium.select { |value| value == :phone }.size
+ end
+
+ should "can assign new values at once to bitmask" do
+ campaign = Campaign.new(:medium => [:web, :print])
+ assert_stored campaign, :web, :print
+ campaign.medium = [:phone, :email]
+ assert_stored campaign, :phone, :email
+ end
+
+ should "can save bitmask to db and retrieve values transparently" do
+ campaign = Campaign.new(:medium => [:web, :print])
+ assert_stored campaign, :web, :print
+ assert campaign.save
+ assert_stored Campaign.find(campaign.id), :web, :print
+ end
+
+ should "can add custom behavor to value proxies during bitmask definition" do
+ campaign = Campaign.new(:medium => [:web, :print])
+ assert_raises NoMethodError do
+ campaign.medium.worked?
+ end
+ assert_nothing_raised do
+ campaign.misc.worked?
+ end
+ assert campaign.misc.worked?
+ end
+
+ should "cannot use unsupported values" do
+ assert_unsupported { Campaign.new(:medium => [:web, :print, :this_will_fail]) }
+ campaign = Campaign.new(:medium => :web)
+ assert_unsupported { campaign.medium << :this_will_fail_also }
+ assert_unsupported { campaign.medium = [:so_will_this] }
+ end
+
+ should "can determine bitmasks using convenience method" do
+ assert Campaign.bitmask_for_medium(:web, :print)
+ assert_equal(
+ Campaign.bitmasks[:medium][:web] | Campaign.bitmasks[:medium][:print],
+ Campaign.bitmask_for_medium(:web, :print)
+ )
+ end
+
+ should "assert use of unknown value in convenience method will result in exception" do
+ assert_unsupported { Campaign.bitmask_for_medium(:web, :and_this_isnt_valid) }
+ end
+
+ should "hash of values is with indifferent access" do
+ string_bit = nil
+ assert_nothing_raised do
+ assert (string_bit = Campaign.bitmask_for_medium('web', 'print'))
+ end
+ assert_equal Campaign.bitmask_for_medium(:web, :print), string_bit
+ end
+
+ #######
+ private
+ #######
+
+ def assert_unsupported(&block)
+ assert_raises(ArgumentError, &block)
+ end
+
+ def assert_stored(record, *values)
+ values.each do |value|
+ assert record.medium.any? { |v| v.to_s == value.to_s }, "Values #{record.medium.inspect} does not include #{value.inspect}"
+ end
+ full_mask = values.inject(0) do |mask, value|
+ mask | Campaign.bitmasks[:medium][value]
+ end
+ assert_equal full_mask, record.medium.to_i
+ end
+
end
+
end
View
47 test/test_helper.rb
@@ -1,10 +1,55 @@
require 'rubygems'
require 'test/unit'
require 'shoulda'
+begin
+ require 'redgreen'
+rescue LoadError
+end
+
+require 'active_support'
+require 'active_record'
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
$LOAD_PATH.unshift(File.dirname(__FILE__))
-require 'bitmask_attribute'
+require 'bitmask-attribute'
+require File.dirname(__FILE__) + '/../rails/init'
+
+ActiveRecord::Base.establish_connection(
+ :adapter => 'sqlite3',
+ :database => ':memory:'
+)
+
+ActiveRecord::Schema.define do
+ create_table :campaigns do |table|
+ table.column :medium, :integer
+ table.column :misc, :integer
+ end
+end
+
+# Pseudo model for testing purposes
+class Campaign < ActiveRecord::Base
+ bitmask :medium, :as => [:web, :print, :email, :phone]
+ bitmask :misc, :as => %w(some useless values) do
+ def worked?
+ true
+ end
+ end
+end
class Test::Unit::TestCase
+
+ def assert_unsupported(&block)
+ assert_raises(ArgumentError, &block)
+ end
+
+ def assert_stored(record, *values)
+ values.each do |value|
+ assert record.medium.any? { |v| v.to_s == value.to_s }, "Values #{record.medium.inspect} does not include #{value.inspect}"
+ end
+ full_mask = values.inject(0) do |mask, value|
+ mask | Campaign.bitmasks[:medium][value]
+ end
+ assert_equal full_mask, record.medium.to_i
+ end
+
end

0 comments on commit ded0bda

Please sign in to comment.