diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..1ce14b0 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 1c0bba9..7ffcd09 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ coverage .idea .idea/* .idea/**/* +*.gem +.bundle +Gemfile.lock +pkg/* diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..1f8609d --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +source "http://rubygems.org" + +gem 'activesupport', '>3.0.0' +# Specify your gem's dependencies in flag_shih_tzu.gemspec +gemspec diff --git a/README.rdoc b/README.rdoc index d2e8fb5..4275842 100755 --- a/README.rdoc +++ b/README.rdoc @@ -1,22 +1,22 @@ =FlagShihTzu -A rails plugin to store a collection of boolean attributes in a single +A rails plugin to store a collection of boolean attributes in a single ActiveRecord column as a bit field. http://github.com/xing/flag_shih_tzu This plugin lets you use a single integer column in an ActiveRecord model -to store a collection of boolean attributes (flags). Each flag can be used -almost in the same way you would use any boolean attribute on an +to store a collection of boolean attributes (flags). Each flag can be used +almost in the same way you would use any boolean attribute on an ActiveRecord object. The benefits: -* No migrations needed for new boolean attributes. This helps a lot +* No migrations needed for new boolean attributes. This helps a lot if you have very large db-tables where you want to avoid ALTER TABLE whenever possible. * Only the one integer column needs to be indexed. -Using FlagShihTzu, you can add new boolean attributes whenever you want, +Using FlagShihTzu, you can add new boolean attributes whenever you want, without needing any migration. Just add a new flag to the +has_flags+ call. And just in case you are wondering what "Shih Tzu" means: @@ -25,20 +25,32 @@ http://en.wikipedia.org/wiki/Shih_Tzu ==Prerequisites -FlagShihTzu assumes that your ActiveRecord model already has an integer field -to store the flags, which should be defined to not allow NULL values and -should have a default value of 0 (which means all flags are initially set to +FlagShihTzu assumes that your ActiveRecord model already has an integer field +to store the flags, which should be defined to not allow NULL values and +should have a default value of 0 (which means all flags are initially set to false). -The plugin has been tested with Rails versions from 2.1 to 2.3 and MySQL, -PostgreSQL and SQLite3 databases. +The plugin has been tested with Rails versions from 2.1 to 3.0 and MySQL, +PostgreSQL and SQLite3 databases. It has been tested with ruby 1.9.2 (with +Rails 3 only). ==Installation +===As plugin (Rails 2.x, Rails 3) + cd path/to/your/rails-project ./script/plugin install git://github.com/xing/flag_shih_tzu.git +===As gem (Rails 3) + +Add following line to your Gemfile: + + gem 'flag_shih_tzu', '= 0.1.0.pre' + +Make sure to install gem with bundler: + + bundle install ==Usage @@ -50,16 +62,85 @@ PostgreSQL and SQLite3 databases. has_flags 1 => :warpdrive, 2 => :shields, 3 => :electrolytes - end -+has_flags+ takes a hash. The keys must be positive integers and represent -the position of the bit being used to enable or disable the flag. ++has_flags+ takes a hash. The keys must be positive integers and represent +the position of the bit being used to enable or disable the flag. The keys must not be changed once in use, or you will get wrong results. That is why the plugin forces you to set them explicitly. The values are symbols for the flags being created. +===Using a custom column name + +The default column name to store the flags is 'flags', but you can provide a +custom column name using the :column option. This allows you to use +different columns for separate flags: + + has_flags 1 => :warpdrive, + 2 => :shields, + 3 => :electrolytes, + :column => 'features' + + has_flags 1 => :spock, + 2 => :scott, + 3 => :kirk, + :column => 'crew' + + +===Generated instance methods + +Calling +has_flags+ as shown above creates the following instance methods +on Spaceship: + + Spaceship#warpdrive + Spaceship#warpdrive? + Spaceship#warpdrive= + Spaceship#shields + Spaceship#shields? + Spaceship#shields= + Spaceship#electrolytes + Spaceship#electrolytes? + Spaceship#electrolytes= + + +===Generated named scopes + +The following named scopes become available: + + Spaceship.warpdrive # :conditions => "(spaceships.flags in (1,3,5,7))" + Spaceship.not_warpdrive # :conditions => "(spaceships.flags not in (1,3,5,7))" + Spaceship.shields # :conditions => "(spaceships.flags in (2,3,6,7))" + Spaceship.not_shields # :conditions => "(spaceships.flags not in (2,3,6,7))" + Spaceship.electrolytes # :conditions => "(spaceships.flags in (4,5,6,7))" + Spaceship.not_electrolytes # :conditions => "(spaceships.flags not in (4,5,6,7))" + +If you do not want the named scopes to be defined, set the +:named_scopes option to false when calling +has_flags+: + + has_flags 1 => :warpdrive, 2 => :shields, 3 => :electrolytes, :named_scopes => false + +In a Rails 3 application, FlagShihTzu will use scope internally to generate +the scopes. The option on has_flags is still named :named_scopes however. + + +===Examples for using the generated methods + + enterprise = Spaceship.new + enterprise.warpdrive = true + enterprise.shields = true + enterprise.electrolytes = false + enterprise.save + + if enterprise.shields? + ... + end + + Spaceship.warpdrive.find(:all) + Spaceship.not_electrolytes.count + ... + + ===How it stores the values As said, FlagShihTzu uses a single integer column to store the values for all @@ -67,7 +148,7 @@ the defined flags as a bit field. The bit position of a flag corresponds to the given key. -This way, we can use bit operators on the stored integer value to set, unset +This way, we can use bit operators on the stored integer value to set, unset and check individual flags. +---+---+---+ +---+---+---+ @@ -94,133 +175,119 @@ and check individual flags. +---+---+---+ +---+---+---+ | 1 | 1 | 0 | = 4 + 2 = 6 | 1 | 0 | 1 | = 4 + 1 = 5 +---+---+---+ +---+---+---+ - + Read more about bit fields here: http://en.wikipedia.org/wiki/Bit_field -===Using a custom column name +===Support for manually building conditions -The default column name to store the flags is 'flags', but you can provide a -custom column name using the :column option: +The following class methods may support you when manually building +ActiveRecord conditions: - has_flags(1 => :warpdrive, :column => 'bits') + Spaceship.warpdrive_condition # "(spaceships.flags in (1,3,5,7))" + Spaceship.not_warpdrive_condition # "(spaceships.flags not in (1,3,5,7))" + Spaceship.shields_condition # "(spaceships.flags in (2,3,6,7))" + Spaceship.not_shields_condition # "(spaceships.flags not in (2,3,6,7))" + Spaceship.electrolytes_condition # "(spaceships.flags in (4,5,6,7))" + Spaceship.not_electrolytes_condition # "(spaceships.flags not in (4,5,6,7))" + +These methods also accept a :table_alias option that can be used when +generating SQL that references the same table more than once: + Spaceship.shields_condition(:table_alias => 'evil_spaceships') # "(evil_spaceships.flags in (2,3,6,7))" -===Generated instance methods -Calling +has_flags+ as shown above creates the following instance methods -on Spaceship: +===Choosing a query mode - Spaceship#warpdrive - Spaceship#warpdrive? - Spaceship#warpdrive= - Spaceship#shields - Spaceship#shields? - Spaceship#shields= - Spaceship#electrolytes - Spaceship#electrolytes? - Spaceship#electrolytes= +While the default way of building the SQL conditions uses an IN() list +(as shown above), this approach will not work well for a high number of flags, +as the value list for IN() grows. +For MySQL, depending on your MySQL settings, this can even hit the +'max_allowed_packet' limit with the generated query. -===Generated named scopes +In this case, consider changing the flag query mode to :bit_operator +instead of :in_list, like so: -The following named scopes become available: + has_flags 1 => :warpdrive, + 2 => :shields, + :flag_query_mode => :bit_operator + +This will modify the generated condition and named_scope methods to use bit +operators in the SQL instead of an IN() list: + + Spaceship.warpdrive_condition # "(spaceships.flags & 1 = 1)", + Spaceship.not_warpdrive_condition # "(spaceships.flags & 1 = 0)", + Spaceship.shields_condition # "(spaceships.flags & 2 = 2)", + Spaceship.not_shields_condition # "(spaceships.flags & 2 = 0)", Spaceship.warpdrive # :conditions => "(spaceships.flags & 1 = 1)" Spaceship.not_warpdrive # :conditions => "(spaceships.flags & 1 = 0)" Spaceship.shields # :conditions => "(spaceships.flags & 2 = 2)" Spaceship.not_shields # :conditions => "(spaceships.flags & 2 = 0)" - Spaceship.electrolytes # :conditions => "(spaceships.flags & 4 = 4)" - Spaceship.not_electrolytes # :conditions => "(spaceships.flags & 4 = 0)" - -If you do not want the named scopes to be defined, set the -:named_scopes option to false when calling +has_flags+: - - has_flags(1 => :warpdrive, 2 => :shields, 3 => :electrolytes, :named_scopes => false) - - -===Support for manually building conditions -The following class methods may support you when manually building -ActiveRecord conditions: - - Spaceship.warpdrive_condition # "(spaceships.flags & 1 = 1)" - Spaceship.not_warpdrive_condition # "(spaceships.flags & 1 = 0)" - Spaceship.shields_condition # "(spaceships.flags & 2 = 2)" - Spaceship.not_shields_condition # "(spaceships.flags & 2 = 0)" - Spaceship.electrolytes_condition # "(spaceships.flags & 4 = 4)" - Spaceship.not_electrolytes_condition # "(spaceships.flags & 4 = 0)" - - -===Example code - - enterprise = Spaceship.new - enterprise.warpdrive = true - enterprise.shields = true - enterprise.electrolytes = false - enterprise.save - - if enterprise.shields? - ... - end - - Spaceship.warpdrive.find(:all) - Spaceship.not_electrolytes.count - ... +The drawback is that due to the bit operator, this query can not use an index +on the flags column. ==Running the plugin tests +1. (Rails 3 only) Add mysql2, pg and sqlite3 gems to your Gemfile. +1. Install flag_shih_tzu as plugin inside working Rails application. 1. Modify test/database.yml to fit your test environment. 2. If needed, create the test database you configured in test/database.yml. -Then you can run - - DB=mysql|postgres|sqlite3 rake test:plugins PLUGIN=flag_shih_tzu - +Then you can run + + DB=mysql|postgres|sqlite3 rake test:plugins PLUGIN=flag_shih_tzu + from your Rails project root or - - DB=mysql|postgres|sqlite3 rake - + + DB=mysql|postgres|sqlite3 rake + from vendor/plugins/flag_shih_tzu. ==Authors -{Patryk Peszko}[http://github.com/ppeszko], -{Sebastian Roebke}[http://github.com/boosty], -{David Anderson}[http://github.com/alpinegizmo] +{Patryk Peszko}[http://github.com/ppeszko], +{Sebastian Roebke}[http://github.com/boosty], +{David Anderson}[http://github.com/alpinegizmo] and {Tim Payton}[http://github.com/dizzy42] -Please find out more about our work in our +Please find out more about our work in our {tech blog}[http://blog.xing.com/category/english/tech-blog]. ==Contributors -{TobiTobes}[http://github.com/rngtng], +{TobiTobes}[http://github.com/rngtng], {Martin Stannard}[http://github.com/martinstannard], {Ladislav Martincik}[http://github.com/lacomartincik], {Peter Boling}[http://github.com/pboling], -{Thorsten Boettger}[http://github.com/alto] +{Daniel Jagszent}[http://github.com/d--j], +{Thorsten Boettger}[http://github.com/alto], +{Darren Torpey}[http://github.com/darrentorpey], +{Joost Baaij}[http://github.com/tilsammans] and +{Musy Bite}[http://github.com/musybite] ==License The MIT License - + Copyright (c) 2009 {XING AG}[http://www.xing.com/] - + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE diff --git a/Rakefile b/Rakefile index 64f4aa4..f71edd8 100755 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,9 @@ require 'rake' require 'rake/testtask' -require 'rake/rdoctask' +require 'rdoc/task' + +require 'bundler' +Bundler::GemHelper.install_tasks desc 'Default: run unit tests.' task :default => :test @@ -13,7 +16,7 @@ Rake::TestTask.new(:test) do |t| end desc 'Generate documentation for the flag_shih_tzu plugin.' -Rake::RDocTask.new(:rdoc) do |rdoc| +RDoc::Task.new do |rdoc| rdoc.rdoc_dir = 'rdoc' rdoc.title = 'FlagShihTzu' rdoc.options << '--line-numbers' << '--inline-source' @@ -28,4 +31,4 @@ namespace :test do system("rcov -Ilib test/*_test.rb") system("open coverage/index.html") if PLATFORM['darwin'] end -end \ No newline at end of file +end diff --git a/flag_shih_tzu.gemspec b/flag_shih_tzu.gemspec new file mode 100644 index 0000000..3f35cb2 --- /dev/null +++ b/flag_shih_tzu.gemspec @@ -0,0 +1,23 @@ +# -*- encoding: utf-8 -*- +$:.push File.expand_path("../lib", __FILE__) +require "flag_shih_tzu/version" + +Gem::Specification.new do |s| + s.name = "flag_shih_tzu" + s.version = FlagShihTzu::VERSION + s.platform = Gem::Platform::RUBY + s.authors = ["Patryk Peszko", "Sebastian Roebke", "David Anderson", "Tim Payton"] + s.homepage = "https://github.com/xing/flag_shih_tzu" + s.summary = %q{A rails plugin to store a collection of boolean attributes in a single ActiveRecord column as a bit field} + s.description = <<-EODOC +This plugin lets you use a single integer column in an ActiveRecord model +to store a collection of boolean attributes (flags). Each flag can be used +almost in the same way you would use any boolean attribute on an +ActiveRecord object. + EODOC + + s.files = `git ls-files`.split("\n") + s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") + s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } + s.require_paths = ["lib"] +end diff --git a/lib/flag_shih_tzu.rb b/lib/flag_shih_tzu.rb index 612820d..78ee7c6 100644 --- a/lib/flag_shih_tzu.rb +++ b/lib/flag_shih_tzu.rb @@ -1,86 +1,112 @@ +require 'active_support/core_ext/class/inheritable_attributes' + module FlagShihTzu TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE'] # taken from ActiveRecord::ConnectionAdapters::Column + DEFAULT_COLUMN_NAME = 'flags' def self.included(base) base.extend(ClassMethods) end - + class IncorrectFlagColumnException < Exception; end + class DuplicateFlagColumnException < Exception; end + class NoSuchFlagQueryModeException < Exception; end class NoSuchFlagException < Exception; end module ClassMethods def has_flags(*args) - flag_hash, options = parse_options(*args) - options = {:named_scopes => true, :column => 'flags', :verbose => false}.update(options) + flag_hash, opts = parse_options(*args) + opts = { + :named_scopes => true, + :column => DEFAULT_COLUMN_NAME, + :flag_query_mode => :in_list + }.update(opts) + colmn = opts[:column].to_s + + return unless check_flag_column(colmn) + + # options are stored in a class level hash and apply per-column + # the mappings are stored in this class level hash and apply per-column + class_eval <<-EVAL + unless defined?(HAS_FLAGS_INITIALIZED) + (class_attribute :flag_columns, :instance_writer => false) + (class_attribute :flag_options, :instance_writer => false) + (class_attribute :flag_mapping, :instance_writer => false) + end - class_inheritable_reader :flag_column - write_inheritable_attribute(:flag_column, options[:column]) + self.flag_columns ||= [] + self.flag_columns += ['#{colmn}'] - flag_column = options[:column] - if check_flag_column(flag_column) + self.flag_options = {} if self.flag_options.nil? + self.flag_options['#{colmn}'] = opts - class_inheritable_hash :flag_mapping # if has_flags is used more than once in a single class, then flag_mapping will already have data in it in successive declarations - write_inheritable_attribute(:flag_mapping, {}) if flag_mapping.nil? - + self.flag_mapping = {} if self.flag_mapping.nil? + #If we already have an instance of the same column in the flag_mapping, then there is a double definition on a column + raise DuplicateFlagColumnException unless self.flag_mapping['#{colmn}'].nil? # initialize flag_mapping for this column - flag_mapping[flag_column] ||= {} + self.flag_mapping['#{colmn}'] ||= {} - flag_hash.each do |flag_key, flag_name| - raise ArgumentError, "has_flags: flag keys should be positive integers, and #{flag_key} is not" unless is_valid_flag_key(flag_key) - raise ArgumentError, "has_flags: flag names should be symbols, and #{flag_name} is not" unless is_valid_flag_name(flag_name) - raise ArgumentError, "has_flags: flag name #{flag_name} already defined, please choose different name" if method_defined?(flag_name) + EVAL - flag_mapping[flag_column][flag_name] = 1 << (flag_key - 1) + flag_hash.each do |flag_key, flag_name| + raise ArgumentError, "has_flags: flag keys should be positive integers, and #{flag_key} is not" unless is_valid_flag_key(flag_key) + raise ArgumentError, "has_flags: flag names should be symbols, and #{flag_name} is not" unless is_valid_flag_name(flag_name) + next if flag_mapping[colmn][flag_name] & (1 << (flag_key - 1)) # next if already methods defined by flag_shih_tzu + raise ArgumentError, "has_flags: flag name #{flag_name} already defined, please choose different name" if method_defined?(flag_name) - class_eval <<-EVAL - def #{flag_name} - flag_enabled?(:#{flag_name}, '#{flag_column}') - end + flag_mapping[colmn][flag_name] = 1 << (flag_key - 1) - def #{flag_name}? - flag_enabled?(:#{flag_name}, '#{flag_column}') - end + # HAS_FLAGS_INITIALIZED: We don't want to initialize has_flags twice for the same class: + class_eval <<-EVAL + HAS_FLAGS_INITIALIZED = true unless defined?(HAS_FLAGS_INITIALIZED) - def #{flag_name}=(value) - FlagShihTzu::TRUE_VALUES.include?(value) ? enable_flag(:#{flag_name}, '#{flag_column}') : disable_flag(:#{flag_name}, '#{flag_column}') - end + def #{flag_name} + flag_enabled?(:#{flag_name}, '#{colmn}') + end - def self.#{flag_name}_condition - sql_condition_for_flag(:#{flag_name}, '#{flag_column}', true) - end + def #{flag_name}? + flag_enabled?(:#{flag_name}, '#{colmn}') + end - def self.not_#{flag_name}_condition - sql_condition_for_flag(:#{flag_name}, '#{flag_column}', false) - end + def #{flag_name}=(value) + FlagShihTzu::TRUE_VALUES.include?(value) ? enable_flag(:#{flag_name}, '#{colmn}') : disable_flag(:#{flag_name}, '#{colmn}') + end - def self.set_#{flag_name}_sql - sql_set_for_flag(:#{flag_name}, '#{flag_column}', true) - end + def self.#{flag_name}_condition(options = {}) + sql_condition_for_flag(:#{flag_name}, '#{colmn}', true, options[:table_alias] || self.table_name) + end - def self.unset_#{flag_name}_sql - sql_set_for_flag(:#{flag_name}, '#{flag_column}', false) - end - EVAL + def self.not_#{flag_name}_condition + sql_condition_for_flag(:#{flag_name}, '#{colmn}', false) + end + + def self.set_#{flag_name}_sql + sql_set_for_flag(:#{flag_name}, '#{colmn}', true) + end - if respond_to?(:named_scope) && options[:named_scopes] - class_eval <<-EVAL - named_scope :#{flag_name}, lambda { { :conditions => #{flag_name}_condition } } - named_scope :not_#{flag_name}, lambda { { :conditions => not_#{flag_name}_condition } } - EVAL + def self.unset_#{flag_name}_sql + sql_set_for_flag(:#{flag_name}, '#{colmn}', false) end + EVAL + + # Define the named scopes if the user wants them and AR supports it + if flag_options[colmn][:named_scopes] && respond_to?(named_scope_method) + class_eval <<-EVAL + #{named_scope_method} :#{flag_name}, lambda { { :conditions => #{flag_name}_condition } } + #{named_scope_method} :not_#{flag_name}, lambda { { :conditions => not_#{flag_name}_condition } } + EVAL end end end def check_flag(flag, colmn) raise ArgumentError, "Column name '#{colmn}' for flag '#{flag}' is not a string" unless colmn.is_a?(String) - raise ArgumentError, "Invalid flag '#{flag}'" if flag_mapping[colmn].nil? || !flag_mapping[colmn].include?(flag) + raise ArgumentError, "Invalid flag '#{flag}'" if self.flag_mapping[colmn].nil? || !self.flag_mapping[colmn].include?(flag) end - private - + def parse_options(*args) options = args.shift if args.size >= 1 @@ -97,38 +123,48 @@ def parse_options(*args) def check_flag_column(colmn, custom_table_name = self.table_name) # If you aren't using ActiveRecord (eg. you are outside rails) then do not fail here # If you are using ActiveRecord then you only want to check for the table if the table exists so it won't fail pre-migration - has_ar = defined?(ActiveRecord) && self.is_a?(ActiveRecord::Base) && ActiveRecord::Base.connected? + has_ar = !!defined?(ActiveRecord) && self.respond_to?(:descends_from_active_record?) # Supposedly Rails 2.3 takes care of this, but this precaution is needed for backwards compatibility has_table = has_ar ? ActiveRecord::Base.connection.tables.include?(custom_table_name) : true - + logger.warn("Error: Table '#{custom_table_name}' doesn't exist") and return false unless has_table if has_table - col = columns.select {|column| column.name == colmn }.first + found_column = columns.find {|column| column.name == colmn} #If you have not yet run the migration that adds the 'flags' column then we don't want to fail, because we need to be able to run the migration #If the column is there but is of the wrong type, then we must fail, because flag_shih_tzu will not work - case col + case found_column when nil then puts "Error: Column '#{colmn}' doesn't exist on table '#{custom_table_name}'. Did you forget to run migrations?" and return false - else raise IncorrectFlagColumnException.new("Table '#{custom_table_name}' must have an integer column named '#{colmn}' in order to use FlagShihTzu") and return false unless col.type == :integer + else raise IncorrectFlagColumnException.new("Table '#{custom_table_name}' must have an integer column named '#{colmn}' in order to use FlagShihTzu") and return false unless found_column.type == :integer end - elsif has_ar && !has_table - puts "Error: Table '#{custom_table_name}' doesn't exist" and return false else #ActiveRecord gem probably hasn't loaded yet? end - return true + true end - def sql_condition_for_flag(flag, colmn, enabled = true, custom_table_name = self.table_name) + def sql_condition_for_flag(flag, colmn, enabled = true, table_name = self.table_name) check_flag(flag, colmn) - "(#{custom_table_name}.#{colmn} & #{flag_mapping[colmn][flag]} = #{enabled ? flag_mapping[colmn][flag] : 0})" + if flag_options[colmn][:flag_query_mode] == :bit_operator + # use & bit operator directly in the SQL query. + # This has the drawback of not using an index on the flags colum. + "(#{table_name}.#{colmn} & #{flag_mapping[colmn][flag]} = #{enabled ? flag_mapping[colmn][flag] : 0})" + elsif flag_options[colmn][:flag_query_mode] == :in_list + # use IN() operator in the SQL query. + # This has the drawback of becoming a big query when you have lots of flags. + neg = enabled ? "" : "not " + "(#{table_name}.#{colmn} #{neg}in (#{sql_in_for_flag(flag, colmn).join(',')}))" + else + raise NoSuchFlagQueryModeException + end end - def sql_set_for_flag(flag, colmn, enabled = true, custom_table_name = self.table_name) - check_flag(flag, colmn) - - "#{custom_table_name}.#{colmn} = #{custom_table_name}.#{colmn} #{enabled ? "| " : "& ~" }#{flag_mapping[colmn][flag]}" + # returns an array of integers suitable for a SQL IN statement. + def sql_in_for_flag(flag, colmn) + val = flag_mapping[colmn][flag] + num = 2 ** flag_mapping[flag_options[colmn][:column]].length + (1..num).select {|i| i & val == val} end - + def is_valid_flag_key(flag_key) flag_key > 0 && flag_key == flag_key.to_i end @@ -136,8 +172,16 @@ def is_valid_flag_key(flag_key) def is_valid_flag_name(flag_name) flag_name.is_a?(Symbol) end + + # Returns the correct method to create a named scope. + # Use to prevent deprecation notices on Rails 3 when using +named_scope+ instead of +scope+. + def named_scope_method + # Can't use respond_to because both AR 2 and 3 respond to both +scope+ and +named_scope+. + ActiveRecord::VERSION::MAJOR == 2 ? :named_scope : :scope + end end + # Performs the bitwise operation so the flag will return +true+. def enable_flag(flag, colmn = nil) colmn = determine_flag_colmn_for(flag) if colmn.nil? self.class.check_flag(flag, colmn) @@ -145,6 +189,7 @@ def enable_flag(flag, colmn = nil) set_flags(self.flags(colmn) | self.class.flag_mapping[colmn][flag], colmn) end + # Performs the bitwise operation so the flag will return +false+. def disable_flag(flag, colmn = nil) colmn = determine_flag_colmn_for(flag) if colmn.nil? self.class.check_flag(flag, colmn) @@ -166,7 +211,7 @@ def flag_disabled?(flag, colmn = nil) !flag_enabled?(flag, colmn) end - def flags(colmn = 'flags') + def flags(colmn = DEFAULT_COLUMN_NAME) self[colmn] || 0 end @@ -181,7 +226,7 @@ def get_bit_for(flag, colmn) end def determine_flag_colmn_for(flag) - return 'flags' if self.class.flag_mapping.nil? + return DEFAULT_COLUMN_NAME if self.class.flag_mapping.nil? self.class.flag_mapping.each_pair do |colmn, mapping| return colmn if mapping.include?(flag) end @@ -189,3 +234,4 @@ def determine_flag_colmn_for(flag) end end + diff --git a/lib/flag_shih_tzu/version.rb b/lib/flag_shih_tzu/version.rb new file mode 100644 index 0000000..728c0f1 --- /dev/null +++ b/lib/flag_shih_tzu/version.rb @@ -0,0 +1,3 @@ +module FlagShihTzu + VERSION = "0.1.0.pre" +end diff --git a/test/database.yml b/test/database.yml index 03473c5..a607142 100644 --- a/test/database.yml +++ b/test/database.yml @@ -3,7 +3,7 @@ sqlite3: database: test/flag_shih_tzu_plugin.sqlite3.db mysql: - adapter: mysql + adapter: mysql2 host: localhost username: root password: diff --git a/test/flag_shih_tzu_test.rb b/test/flag_shih_tzu_test.rb index 55487ad..d6a2115 100755 --- a/test/flag_shih_tzu_test.rb +++ b/test/flag_shih_tzu_test.rb @@ -1,4 +1,4 @@ -require File.dirname(__FILE__) + '/test_helper.rb' +require File.expand_path(File.dirname(__FILE__) + '/test_helper.rb') load_schema class Spaceship < ActiveRecord::Base @@ -39,10 +39,25 @@ class SpaceshipWith2CustomFlagsColumn < ActiveRecord::Base has_flags({ 1 => :jeanlucpicard, 2 => :dajanatroj }, :column => 'commanders') end +class SpaceshipWithBitOperatorQueryMode < ActiveRecord::Base + set_table_name 'spaceships' + include FlagShihTzu + + has_flags(1 => :warpdrive, 2 => :shields, :flag_query_mode => :bit_operator) +end + class SpaceCarrier < Spaceship end +# table planets is missing intentionally to see if flagshihtzu handles missing tables gracefully +class Planet < ActiveRecord::Base +end + class FlagShihTzuClassMethodsTest < Test::Unit::TestCase + +# def setup +# Spaceship.destroy_all +# end def test_has_flags_should_raise_an_exception_when_flag_key_is_negative assert_raises ArgumentError do @@ -72,6 +87,53 @@ class SpaceshipWithAlreadyUsedFlag < ActiveRecord::Base ) end end + + def test_has_flags_should_raise_an_exception_when_desired_flag_name_method_already_defined + assert_raises ArgumentError do + eval(<<-EOF + class SpaceshipWithAlreadyUsedMethod < ActiveRecord::Base + set_table_name 'spaceships_with_2_custom_flags_column' + include FlagShihTzu + + def jeanluckpicard; end + + has_flags({ 1 => :jeanluckpicard }, :column => 'bits') + end + EOF + ) + end + end + + + def test_has_flags_should_raise_an_exception_when_flag_column_defined_twice + assert_raises FlagShihTzu::DuplicateFlagColumnException do + eval(<<-EOF + class SpaceshipWithDuplicateFlagsColumn < ActiveRecord::Base + set_table_name 'spaceships_with_2_custom_flags_column' + include FlagShihTzu + + has_flags({ 1 => :warpdrive, 2 => :hyperspace }, :column => 'bits') + has_flags({ 1 => :jeanlucpicard, 2 => :dajanatroj }, :column => 'bits') + end + EOF + ) + end + end + + def test_has_flags_should_not_raise_an_exception_when_mulitple_has_flags_definitions_on_different_columns + assert_nothing_raised do + eval(<<-EOF + class SpaceshipWithAlreadyUsedMethodByFlagshitzu < ActiveRecord::Base + set_table_name 'spaceships_with_2_custom_flags_column' + include FlagShihTzu + + has_flags({ 1 => :jeanluckpicard }, :column => 'bits') + has_flags({ 1 => :mangoes }, :column => 'commanders') + end + EOF + ) + end + end def test_has_flags_should_raise_an_exception_when_flag_name_is_not_a_symbol assert_raises ArgumentError do @@ -88,59 +150,89 @@ class SpaceshipWithInvalidFlagName < ActiveRecord::Base end def test_should_define_a_sql_condition_method_for_flag_enabled - assert_equal "(spaceships.flags & 1 = 1)", Spaceship.warpdrive_condition - assert_equal "(spaceships.flags & 2 = 2)", Spaceship.shields_condition - assert_equal "(spaceships.flags & 4 = 4)", Spaceship.electrolytes_condition + assert_equal "(spaceships.flags in (1,3,5,7))", Spaceship.warpdrive_condition + assert_equal "(spaceships.flags in (2,3,6,7))", Spaceship.shields_condition + assert_equal "(spaceships.flags in (4,5,6,7))", Spaceship.electrolytes_condition + end + + def test_should_accept_a_table_alias_option_for_sql_condition_method + assert_equal "(old_spaceships.flags in (1,3,5,7))", Spaceship.warpdrive_condition(:table_alias => 'old_spaceships') end def test_should_define_a_sql_condition_method_for_flag_enabled_with_2_colmns - assert_equal "(spaceships_with_2_custom_flags_column.bits & 1 = 1)", SpaceshipWith2CustomFlagsColumn.warpdrive_condition - assert_equal "(spaceships_with_2_custom_flags_column.bits & 2 = 2)", SpaceshipWith2CustomFlagsColumn.hyperspace_condition - assert_equal "(spaceships_with_2_custom_flags_column.commanders & 1 = 1)", SpaceshipWith2CustomFlagsColumn.jeanlucpicard_condition - assert_equal "(spaceships_with_2_custom_flags_column.commanders & 2 = 2)", SpaceshipWith2CustomFlagsColumn.dajanatroj_condition + assert_equal "(spaceships_with_2_custom_flags_column.bits in (1,3))", SpaceshipWith2CustomFlagsColumn.warpdrive_condition + assert_equal "(spaceships_with_2_custom_flags_column.bits in (2,3))", SpaceshipWith2CustomFlagsColumn.hyperspace_condition + assert_equal "(spaceships_with_2_custom_flags_column.commanders in (1,3))", SpaceshipWith2CustomFlagsColumn.jeanlucpicard_condition + assert_equal "(spaceships_with_2_custom_flags_column.commanders in (2,3))", SpaceshipWith2CustomFlagsColumn.dajanatroj_condition end def test_should_define_a_sql_condition_method_for_flag_not_enabled - assert_equal "(spaceships.flags & 1 = 0)", Spaceship.not_warpdrive_condition - assert_equal "(spaceships.flags & 2 = 0)", Spaceship.not_shields_condition - assert_equal "(spaceships.flags & 4 = 0)", Spaceship.not_electrolytes_condition + assert_equal "(spaceships.flags not in (1,3,5,7))", Spaceship.not_warpdrive_condition + assert_equal "(spaceships.flags not in (2,3,6,7))", Spaceship.not_shields_condition + assert_equal "(spaceships.flags not in (4,5,6,7))", Spaceship.not_electrolytes_condition end def test_should_define_a_sql_condition_method_for_flag_enabled_with_custom_table_name - assert_equal "(custom_spaceships.flags & 1 = 1)", Spaceship.send( :sql_condition_for_flag, :warpdrive, 'flags', true, 'custom_spaceships') + assert_equal "(custom_spaceships.flags in (1,3,5,7))", Spaceship.send( :sql_condition_for_flag, :warpdrive, 'flags', true, 'custom_spaceships') end def test_should_define_a_sql_condition_method_for_flag_enabled_with_2_colmns_not_enabled - assert_equal "(spaceships_with_2_custom_flags_column.bits & 1 = 0)", SpaceshipWith2CustomFlagsColumn.not_warpdrive_condition - assert_equal "(spaceships_with_2_custom_flags_column.bits & 2 = 0)", SpaceshipWith2CustomFlagsColumn.not_hyperspace_condition - assert_equal "(spaceships_with_2_custom_flags_column.commanders & 1 = 0)", SpaceshipWith2CustomFlagsColumn.not_jeanlucpicard_condition - assert_equal "(spaceships_with_2_custom_flags_column.commanders & 2 = 0)", SpaceshipWith2CustomFlagsColumn.not_dajanatroj_condition + assert_equal "(spaceships_with_2_custom_flags_column.bits not in (1,3))", SpaceshipWith2CustomFlagsColumn.not_warpdrive_condition + assert_equal "(spaceships_with_2_custom_flags_column.bits not in (2,3))", SpaceshipWith2CustomFlagsColumn.not_hyperspace_condition + assert_equal "(spaceships_with_2_custom_flags_column.commanders not in (1,3))", SpaceshipWith2CustomFlagsColumn.not_jeanlucpicard_condition + assert_equal "(spaceships_with_2_custom_flags_column.commanders not in (2,3))", SpaceshipWith2CustomFlagsColumn.not_dajanatroj_condition + end + + def test_should_define_a_sql_condition_method_for_flag_enabled_using_bit_operators + assert_equal "(spaceships.flags & 1 = 1)", SpaceshipWithBitOperatorQueryMode.warpdrive_condition + assert_equal "(spaceships.flags & 2 = 2)", SpaceshipWithBitOperatorQueryMode.shields_condition + end + + def test_should_define_a_sql_condition_method_for_flag_not_enabled_using_bit_operators + assert_equal "(spaceships.flags & 1 = 0)", SpaceshipWithBitOperatorQueryMode.not_warpdrive_condition + assert_equal "(spaceships.flags & 2 = 0)", SpaceshipWithBitOperatorQueryMode.not_shields_condition end def test_should_define_a_named_scope_for_flag_enabled - assert_equal({ :conditions => "(spaceships.flags & 1 = 1)" }, Spaceship.warpdrive.proxy_options) - assert_equal({ :conditions => "(spaceships.flags & 2 = 2)" }, Spaceship.shields.proxy_options) - assert_equal({ :conditions => "(spaceships.flags & 4 = 4)" }, Spaceship.electrolytes.proxy_options) + assert_equal(["(spaceships.flags in (1,3,5,7))"], Spaceship.warpdrive.where_values) + assert_equal(["(spaceships.flags in (2,3,6,7))"], Spaceship.shields.where_values) + assert_equal(["(spaceships.flags in (4,5,6,7))"], Spaceship.electrolytes.where_values) end def test_should_define_a_named_scope_for_flag_not_enabled - assert_equal({ :conditions => "(spaceships.flags & 1 = 0)" }, Spaceship.not_warpdrive.proxy_options) - assert_equal({ :conditions => "(spaceships.flags & 2 = 0)" }, Spaceship.not_shields.proxy_options) - assert_equal({ :conditions => "(spaceships.flags & 4 = 0)" }, Spaceship.not_electrolytes.proxy_options) + assert_equal(["(spaceships.flags not in (1,3,5,7))"], Spaceship.not_warpdrive.where_values) + assert_equal(["(spaceships.flags not in (2,3,6,7))"], Spaceship.not_shields.where_values) + assert_equal(["(spaceships.flags not in (4,5,6,7))"], Spaceship.not_electrolytes.where_values) end - def test_should_define_a_named_scope_for_flag_enabled_with_2_columns - assert_equal({ :conditions => "(spaceships_with_2_custom_flags_column.bits & 1 = 1)" }, SpaceshipWith2CustomFlagsColumn.warpdrive.proxy_options) - assert_equal({ :conditions => "(spaceships_with_2_custom_flags_column.bits & 2 = 2)" }, SpaceshipWith2CustomFlagsColumn.hyperspace.proxy_options) - assert_equal({ :conditions => "(spaceships_with_2_custom_flags_column.commanders & 1 = 1)" }, SpaceshipWith2CustomFlagsColumn.jeanlucpicard.proxy_options) - assert_equal({ :conditions => "(spaceships_with_2_custom_flags_column.commanders & 2 = 2)" }, SpaceshipWith2CustomFlagsColumn.dajanatroj.proxy_options) + def test_should_define_a_named_scope_for_flag_enabled_with_2_columns_1 + assert_equal(["(spaceships_with_2_custom_flags_column.bits in (1,3))"], SpaceshipWith2CustomFlagsColumn.warpdrive.where_values) + end + def test_should_define_a_named_scope_for_flag_enabled_with_2_columns_2 + assert_equal(["(spaceships_with_2_custom_flags_column.bits in (2,3))"], SpaceshipWith2CustomFlagsColumn.hyperspace.where_values) + end + def test_should_define_a_named_scope_for_flag_enabled_with_2_columns_3 + assert_equal(["(spaceships_with_2_custom_flags_column.commanders in (1,3))"], SpaceshipWith2CustomFlagsColumn.jeanlucpicard.where_values) + end + def test_should_define_a_named_scope_for_flag_enabled_with_2_columns_4 + assert_equal(["(spaceships_with_2_custom_flags_column.commanders in (2,3))"], SpaceshipWith2CustomFlagsColumn.dajanatroj.where_values) end def test_should_define_a_named_scope_for_flag_not_enabled_with_2_columns - assert_equal({ :conditions => "(spaceships_with_2_custom_flags_column.bits & 1 = 0)" }, SpaceshipWith2CustomFlagsColumn.not_warpdrive.proxy_options) - assert_equal({ :conditions => "(spaceships_with_2_custom_flags_column.bits & 2 = 0)" }, SpaceshipWith2CustomFlagsColumn.not_hyperspace.proxy_options) - assert_equal({ :conditions => "(spaceships_with_2_custom_flags_column.commanders & 1 = 0)" }, SpaceshipWith2CustomFlagsColumn.not_jeanlucpicard.proxy_options) - assert_equal({ :conditions => "(spaceships_with_2_custom_flags_column.commanders & 2 = 0)" }, SpaceshipWith2CustomFlagsColumn.not_dajanatroj.proxy_options) + assert_equal(["(spaceships_with_2_custom_flags_column.bits not in (1,3))"], SpaceshipWith2CustomFlagsColumn.not_warpdrive.where_values) + assert_equal(["(spaceships_with_2_custom_flags_column.bits not in (2,3))"], SpaceshipWith2CustomFlagsColumn.not_hyperspace.where_values) + assert_equal(["(spaceships_with_2_custom_flags_column.commanders not in (1,3))"], SpaceshipWith2CustomFlagsColumn.not_jeanlucpicard.where_values) + assert_equal(["(spaceships_with_2_custom_flags_column.commanders not in (2,3))"], SpaceshipWith2CustomFlagsColumn.not_dajanatroj.where_values) + end + + def test_should_define_a_named_scope_for_flag_enabled_using_bit_operators + assert_equal(["(spaceships.flags & 1 = 1)"], SpaceshipWithBitOperatorQueryMode.warpdrive.where_values) + assert_equal(["(spaceships.flags & 2 = 2)"], SpaceshipWithBitOperatorQueryMode.shields.where_values) + end + + def test_should_define_a_named_scope_for_flag_not_enabled_using_bit_operators + assert_equal(["(spaceships.flags & 1 = 0)"], SpaceshipWithBitOperatorQueryMode.not_warpdrive.where_values) + assert_equal(["(spaceships.flags & 2 = 0)"], SpaceshipWithBitOperatorQueryMode.not_shields.where_values) end def test_should_return_the_correct_number_of_items_from_a_named_scope @@ -177,16 +269,25 @@ def test_should_work_with_a_custom_flags_column spaceship.save! spaceship.reload assert_equal 3, spaceship.flags('bits') - assert_equal "(spaceships_with_custom_flags_column.bits & 1 = 1)", SpaceshipWithCustomFlagsColumn.warpdrive_condition - assert_equal "(spaceships_with_custom_flags_column.bits & 1 = 0)", SpaceshipWithCustomFlagsColumn.not_warpdrive_condition - assert_equal "(spaceships_with_custom_flags_column.bits & 2 = 2)", SpaceshipWithCustomFlagsColumn.hyperspace_condition - assert_equal "(spaceships_with_custom_flags_column.bits & 2 = 0)", SpaceshipWithCustomFlagsColumn.not_hyperspace_condition - assert_equal({ :conditions => "(spaceships_with_custom_flags_column.bits & 1 = 1)" }, SpaceshipWithCustomFlagsColumn.warpdrive.proxy_options) - assert_equal({ :conditions => "(spaceships_with_custom_flags_column.bits & 1 = 0)" }, SpaceshipWithCustomFlagsColumn.not_warpdrive.proxy_options) - assert_equal({ :conditions => "(spaceships_with_custom_flags_column.bits & 2 = 2)" }, SpaceshipWithCustomFlagsColumn.hyperspace.proxy_options) - assert_equal({ :conditions => "(spaceships_with_custom_flags_column.bits & 2 = 0)" }, SpaceshipWithCustomFlagsColumn.not_hyperspace.proxy_options) + assert_equal "(spaceships_with_custom_flags_column.bits in (1,3))", SpaceshipWithCustomFlagsColumn.warpdrive_condition + assert_equal "(spaceships_with_custom_flags_column.bits not in (1,3))", SpaceshipWithCustomFlagsColumn.not_warpdrive_condition + assert_equal "(spaceships_with_custom_flags_column.bits in (2,3))", SpaceshipWithCustomFlagsColumn.hyperspace_condition + assert_equal "(spaceships_with_custom_flags_column.bits not in (2,3))", SpaceshipWithCustomFlagsColumn.not_hyperspace_condition + assert_equal(["(spaceships_with_custom_flags_column.bits in (1,3))"], SpaceshipWithCustomFlagsColumn.warpdrive.where_values) + assert_equal(["(spaceships_with_custom_flags_column.bits not in (1,3))"], SpaceshipWithCustomFlagsColumn.not_warpdrive.where_values) + assert_equal(["(spaceships_with_custom_flags_column.bits in (2,3))"], SpaceshipWithCustomFlagsColumn.hyperspace.where_values) + assert_equal(["(spaceships_with_custom_flags_column.bits not in (2,3))"], SpaceshipWithCustomFlagsColumn.not_hyperspace.where_values) end - + + def test_should_not_error_out_when_table_is_not_present + assert_nothing_raised(ActiveRecord::StatementInvalid) do + Planet.class_eval do + include FlagShihTzu + has_flags(1 => :habitable) + end + end + end + end class FlagShihTzuInstanceMethodsTest < Test::Unit::TestCase @@ -296,7 +397,26 @@ def test_should_respect_true_values_like_active_record assert !@spaceship.warpdrive end end - + +#This is a key operational difference between pboling branch on kuzmann +# def test_should_ignore_has_flags_call_if_column_does_not_exist_yet +# assert_nothing_raised do +# eval(<<-EOF +# class SpaceshipWithoutFlagsColumn < ActiveRecord::Base +# set_table_name 'spaceships_without_flags_column' +# include FlagShihTzu +# +# has_flags 1 => :warpdrive, +# 2 => :shields, +# 3 => :electrolytes +# end +# EOF +# ) +# end +# +# assert !SpaceshipWithoutFlagsColumn.method_defined?(:warpdrive) +# end + def test_check_flag_column_raises_error_if_column_not_in_list_of_attributes assert_raises FlagShihTzu::IncorrectFlagColumnException do @spaceship.class.send(:check_flag_column, 'incorrect_flags_column') @@ -397,9 +517,4 @@ def test_should_respect_true_values_like_active_record assert !@spaceship.warpdrive end end - - def test_should_return_a_sql_set_method_for_flag - assert_equal "spaceships.flags = spaceships.flags | 1", Spaceship.send( :sql_set_for_flag, :warpdrive, 'flags', true) - assert_equal "spaceships.flags = spaceships.flags & ~1", Spaceship.send( :sql_set_for_flag, :warpdrive, 'flags', false) - end end diff --git a/test/schema.rb b/test/schema.rb index a1adba0..e831d01 100644 --- a/test/schema.rb +++ b/test/schema.rb @@ -14,8 +14,10 @@ t.integer :commanders, :null => false, :default => 0 end - create_table :spaceships_with_non_integer_flags_column, :force => true do |t| - t.string :flags, :null => false, :default => 0 + create_table :spaceships_without_flags_column, :force => true do |t| end + create_table :spaceships_with_non_integer_column, :force => true do |t| + t.string :flags, :null => false, :default => 'A string' + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 467ca67..8f3f814 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -7,9 +7,11 @@ require 'test/unit' require 'yaml' +require 'logger' require 'rubygems' +gem 'activerecord', '~> 3.0' require 'active_record' -require 'flag_shih_tzu' +require 'flag_shih_tzu' def load_schema config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))