Skip to content
This repository has been archived by the owner on Jan 8, 2023. It is now read-only.

Commit

Permalink
Initial commit.
Browse files Browse the repository at this point in the history
  • Loading branch information
maxim committed Dec 2, 2009
1 parent ba8263a commit fefa06a
Show file tree
Hide file tree
Showing 13 changed files with 532 additions and 0 deletions.
21 changes: 21 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## MAC OS
.DS_Store

## TEXTMATE
*.tmproj
tmtags

## EMACS
*~
\#*
.\#*

## VIM
*.swp

## PROJECT::GENERAL
coverage
rdoc
doc
pkg
.yardoc
54 changes: 54 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
require 'rubygems'
require 'rake'

begin
require 'jeweler'
Jeweler::Tasks.new do |gem|
gem.name = "has_price"
gem.summary = %Q{Provides a convenient DSL for organizing a price breakdown in a class.}
gem.description = %Q{A convenient DSL for defining complex price reader/serializer in a class and organizing a price breakdown. Price can be declared with items and groups which depend on other attributes. Price is a very simple subclass of Hash. This provides for easy serialization and flexibility in case of implementation changes. This way you can conveniently store the whole price breakdown in your serialized receipts. It also provides magic methods for convenient access, but can be fully treated as a regular Hash with some sprinkles on top.}
gem.email = "max@bitsonnet.com"
gem.homepage = "http://github.com/maxim/has_price"
gem.authors = ["Maxim Chernyak"]
gem.add_development_dependency "shoulda", ">= 0"
gem.add_development_dependency "rr"
gem.files.include %w(lib/*/**)
end
Jeweler::GemcutterTasks.new
rescue LoadError
puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
end

require 'rake/testtask'
Rake::TestTask.new(:test) do |test|
test.libs << 'lib' << 'test'
test.pattern = 'test/**/test_*.rb'
test.verbose = true
end

begin
require 'rcov/rcovtask'
Rcov::RcovTask.new do |test|
test.libs << 'test'
test.pattern = 'test/**/test_*.rb'
test.verbose = true
end
rescue LoadError
task :rcov do
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
end
end

task :test => :check_dependencies

task :default => :test

require 'rake/rdoctask'
Rake::RDocTask.new do |rdoc|
version = File.exist?('VERSION') ? File.read('VERSION') : ""

rdoc.rdoc_dir = 'rdoc'
rdoc.title = "has_price #{version}"
rdoc.rdoc_files.include('README*')
rdoc.rdoc_files.include('lib/**/*.rb')
end
14 changes: 14 additions & 0 deletions lib/has_price.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
require File.dirname(__FILE__) + "/has_price/core_extensions/array.rb"
require File.dirname(__FILE__) + "/has_price/core_extensions/string.rb"

unless Array.instance_methods.include? "extract_options!"
Array.send :include, HasPrice::CoreExtensions::Array
end

unless String.instance_methods.include? "underscore"
String.send :include, HasPrice::CoreExtensions::String
end

require File.dirname(__FILE__) + "/has_price/price.rb"
require File.dirname(__FILE__) + "/has_price/price_builder.rb"
require File.dirname(__FILE__) + "/has_price/has_price.rb"
12 changes: 12 additions & 0 deletions lib/has_price/core_extensions/array.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module HasPrice
module CoreExtensions
module Array
# In case we're not in Rails.
#
# @see http://api.rubyonrails.org/classes/ActiveSupport/CoreExtensions/Array/ExtractOptions.html#M001202
def extract_options!
last.is_a?(::Hash) ? pop : {}
end
end
end
end
16 changes: 16 additions & 0 deletions lib/has_price/core_extensions/string.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module HasPrice
module CoreExtensions
module String
# In case we're not in Rails.
#
# @see http://api.rubyonrails.org/classes/Inflector.html#M001631
def underscore
gsub(/::/, '/').
gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
gsub(/([a-z\d])([A-Z])/,'\1_\2').
tr("-", "_").
downcase
end
end
end
end
52 changes: 52 additions & 0 deletions lib/has_price/has_price.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
module HasPrice
module HasPrice

# Provides a simple DSL to defines price instance method on the receiver.
#
# @param [Hash] options the options for creating price method.
# @option options [Symbol] :attribute (:price) Name of the price method.
# @option options [Boolean] :free (false) Set `:free => true` to use null object pattern.
#
# @yield The yielded block provides method `item` for declaring price entries,
# and method `group` for declaring price groups.
#
# @example Normal usage
# class Product < ActiveRecord::Base
# has_price do
# item base_price, "base"
# item discount, "discount"
#
# group "taxes" do
# item federal_tax, "federal tax"
# item state_tax, "state tax"
# end
#
# group "shipment" do
# # Notice that delivery_method is an instance method.
# # You can call instance methods anywhere in has_price block.
# item delivery_price, delivery_method
# end
# end
# end
#
# @example Null object pattern
# class Product < ActiveRecord::Base
# # Creates method #price which returns empty Price.
# has_price :free => true
# end
#
# @see PriceBuilder#item
# @see PriceBuilder#group
#
def has_price(options = {}, &block)
attribute = options[:attribute] || :price
free = !block_given? && options[:free]

define_method attribute.to_sym do
builder = PriceBuilder.new self
builder.instance_eval &block unless free
builder.price
end
end
end
end
52 changes: 52 additions & 0 deletions lib/has_price/price.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
module HasPrice
class Price < Hash
# @return [Fixnum] the recursive sum of all declared prices.
def to_i; recursive_sum end
# @return [String] the output of to_i converted to string.
def to_s; to_i.to_s end
# @return [Hash] the price as a Hash object.
def to_hash; Hash[self] end
alias total to_i

# Provides access to price items and groups using magic methods and chaining.
#
# @example
# class Product
# has_price do
# item 400, "base"
# group "tax" do
# item 100, "federal"
# item 50, "state"
# end
# end
# end
#
# product = Product.new
# product.price # => Full Price object
# product.price.base # => 400
# product.price.tax # => Price object on group tax
# product.price.tax.federal # => 100
# product.price.tax.total # => 150
#
# @return [Price, Fixnum] Price object if method matches a group, Fixnum if method matches an item.
def method_missing(meth, *args, &blk)
value = select{|k,v| k.underscore == meth.to_s}.first

if !value
super
elsif value.last.is_a?(Hash)
self.class[value.last]
else
value.last
end
end

private
def recursive_sum(target = self)
target.inject(0) do |sum, pair|
value = pair.last
sum += value.is_a?(Hash) ? recursive_sum(value) : value
end
end
end
end
88 changes: 88 additions & 0 deletions lib/has_price/price_builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
module HasPrice
class PriceBuilder
attr_reader :price

# Creates PriceBuilder on a target object.
#
# @param [Object] object the target object on which price is being built.
def initialize(object)
@price = Price.new
@current_nesting_level = @price
@object = object
end

# Adds price item to the current nesting level of price definition.
#
# @param [#to_hash, #to_i] price an integer representing amount for this price item.
# Alternatively, anything that responds to #to_hash can be used,
# and will be treated as a group named with item_name.
# @param [#to_s] item_name name for the provided price item or group.
#
# @see #group
def item(price, item_name)
@current_nesting_level[item_name.to_s] = price.respond_to?(:to_hash) ? price.to_hash : price.to_i
end

# Adds price group to the current nesting level of price definition.
# Groups are useful for price breakdown categorization and easy subtotal values.
#
# @example Using group subtotals
# class Product
# include HasPrice
#
# def base_price; 100 end
# def federal_tax; 15 end
# def state_tax; 10 end
#
# has_price do
# item base_price, "base"
# group "tax" do
# item federal_tax, "federal"
# item state_tax, "state"
# end
# end
# end
#
# @product = Product.new
# @product.price.total # => 125
# @product.price.tax.total # => 25
#
# @param [#to_s] group_name a name for the price group
# @yield The yielded block is executed within the group, such that all groups and items
# declared within the block appear nested under this group. This behavior is recursive.
#
# @see #item
def group(group_name, &block)
group_key = group_name.to_s

@current_nesting_level[group_key] ||= {}

if block_given?
within_group(group_key) do
instance_eval &block
end
end
end

# Delegates all missing methods to the target object.
def method_missing(meth, *args, &block)
@object.send(meth, *args, &block)
end

private
def within_group(group_name)
step_into group_name
yield
step_out
end

def step_into(group_name)
@original_nesting_level = @current_nesting_level
@current_nesting_level = @current_nesting_level[group_name]
end

def step_out
@current_nesting_level = @original_nesting_level
end
end
end
3 changes: 3 additions & 0 deletions rails/init.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
require 'has_price'

ActiveRecord::Base.extend HasPrice::HasPrice
13 changes: 13 additions & 0 deletions test/helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
require 'rubygems'
require 'test/unit'
require 'shoulda'
require 'rr'

$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
$LOAD_PATH.unshift(File.dirname(__FILE__))
require 'has_price'

class Test::Unit::TestCase
include RR::Adapters::TestUnit
include HasPrice
end
Loading

0 comments on commit fefa06a

Please sign in to comment.