invoicing 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +3 -0
- data/LICENSE +20 -0
- data/Manifest +60 -0
- data/README +48 -0
- data/Rakefile +75 -0
- data/invoicing.gemspec +41 -0
- data/lib/invoicing.rb +9 -0
- data/lib/invoicing/cached_record.rb +107 -0
- data/lib/invoicing/class_info.rb +187 -0
- data/lib/invoicing/connection_adapter_ext.rb +44 -0
- data/lib/invoicing/countries/uk.rb +24 -0
- data/lib/invoicing/currency_value.rb +212 -0
- data/lib/invoicing/find_subclasses.rb +193 -0
- data/lib/invoicing/ledger_item.rb +718 -0
- data/lib/invoicing/ledger_item/render_html.rb +515 -0
- data/lib/invoicing/ledger_item/render_ubl.rb +268 -0
- data/lib/invoicing/line_item.rb +246 -0
- data/lib/invoicing/price.rb +9 -0
- data/lib/invoicing/tax_rate.rb +9 -0
- data/lib/invoicing/taxable.rb +355 -0
- data/lib/invoicing/time_dependent.rb +388 -0
- data/lib/invoicing/version.rb +21 -0
- data/test/cached_record_test.rb +100 -0
- data/test/class_info_test.rb +253 -0
- data/test/connection_adapter_ext_test.rb +71 -0
- data/test/currency_value_test.rb +184 -0
- data/test/find_subclasses_test.rb +120 -0
- data/test/fixtures/README +7 -0
- data/test/fixtures/cached_record.sql +22 -0
- data/test/fixtures/class_info.sql +28 -0
- data/test/fixtures/currency_value.sql +29 -0
- data/test/fixtures/find_subclasses.sql +43 -0
- data/test/fixtures/ledger_item.sql +39 -0
- data/test/fixtures/line_item.sql +33 -0
- data/test/fixtures/price.sql +4 -0
- data/test/fixtures/tax_rate.sql +4 -0
- data/test/fixtures/taxable.sql +14 -0
- data/test/fixtures/time_dependent.sql +35 -0
- data/test/ledger_item_test.rb +352 -0
- data/test/line_item_test.rb +139 -0
- data/test/models/README +4 -0
- data/test/models/test_subclass_in_another_file.rb +3 -0
- data/test/models/test_subclass_not_in_database.rb +6 -0
- data/test/price_test.rb +9 -0
- data/test/ref-output/creditnote3.html +82 -0
- data/test/ref-output/creditnote3.xml +89 -0
- data/test/ref-output/invoice1.html +93 -0
- data/test/ref-output/invoice1.xml +111 -0
- data/test/ref-output/invoice2.html +86 -0
- data/test/ref-output/invoice2.xml +98 -0
- data/test/ref-output/invoice_null.html +36 -0
- data/test/render_html_test.rb +69 -0
- data/test/render_ubl_test.rb +32 -0
- data/test/setup.rb +37 -0
- data/test/tax_rate_test.rb +9 -0
- data/test/taxable_test.rb +180 -0
- data/test/test_helper.rb +48 -0
- data/test/time_dependent_test.rb +180 -0
- data/website/curvycorners.js +1 -0
- data/website/screen.css +149 -0
- data/website/template.html.erb +43 -0
- metadata +180 -0
@@ -0,0 +1,44 @@
|
|
1
|
+
module Invoicing
|
2
|
+
# Extensions specific to certain database adapters. Currently only MySQL and PostgreSQL are
|
3
|
+
# supported.
|
4
|
+
class ConnectionAdapterExt
|
5
|
+
|
6
|
+
# Creates a database-specific SQL fragment for evaluating a three-legged conditional function
|
7
|
+
# in a query.
|
8
|
+
def self.conditional_function(condition, value_if_true, value_if_false)
|
9
|
+
case ActiveRecord::Base.connection.adapter_name
|
10
|
+
when "MySQL"
|
11
|
+
"IF(#{condition}, #{value_if_true}, #{value_if_false})"
|
12
|
+
when "PostgreSQL"
|
13
|
+
"CASE WHEN #{condition} THEN #{value_if_true} ELSE #{value_if_false} END"
|
14
|
+
else
|
15
|
+
raise "Database adapter #{ActiveRecord::Base.connection.adapter_name} not supported by invoicing gem"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# Suppose <tt>A has_many B</tt>, and you want to select all As, counting for each A how many
|
20
|
+
# Bs it has. In MySQL you can just say:
|
21
|
+
# SELECT A.*, COUNT(B.id) AS number_of_bs FROM A LEFT JOIN B on A.id = B.a_id GROUP BY A.id
|
22
|
+
# PostgreSQL, however, doesn't like you selecting a column from A if that column is neither
|
23
|
+
# in the <tt>GROUP BY</tt> clause nor wrapped in an aggregation function (even though it is
|
24
|
+
# implicitly grouped by through the fact that <tt>A.id</tt> is unique per row). Therefore
|
25
|
+
# for PostgreSQL, we need to explicitly list all of A's columns in the <tt>GROUP BY</tt>
|
26
|
+
# clause.
|
27
|
+
#
|
28
|
+
# This method takes a model class (a subclass of <tt>ActiveRecord::Base</tt>) and returns
|
29
|
+
# a string suitable to be used as the contents of the <tt>GROUP BY</tt> clause.
|
30
|
+
def self.group_by_all_columns(model_class)
|
31
|
+
case ActiveRecord::Base.connection.adapter_name
|
32
|
+
when "MySQL"
|
33
|
+
model_class.quoted_table_name + "." +
|
34
|
+
ActiveRecord::Base.connection.quote_column_name(model_class.primary_key)
|
35
|
+
when "PostgreSQL"
|
36
|
+
model_class.column_names.map{ |column|
|
37
|
+
model_class.quoted_table_name + "." + ActiveRecord::Base.connection.quote_column_name(column)
|
38
|
+
}.join(', ')
|
39
|
+
else
|
40
|
+
raise "Database adapter #{ActiveRecord::Base.connection.adapter_name} not supported by invoicing gem"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Invoicing
|
2
|
+
module Countries
|
3
|
+
module UK
|
4
|
+
# Extremely simplistic implementation of UK VAT. This needs to be fixed.
|
5
|
+
class VAT
|
6
|
+
def apply_tax(params)
|
7
|
+
params[:value] * BigDecimal('1.15')
|
8
|
+
end
|
9
|
+
|
10
|
+
def remove_tax(params)
|
11
|
+
params[:value] / BigDecimal('1.15')
|
12
|
+
end
|
13
|
+
|
14
|
+
def tax_info(params)
|
15
|
+
"(inc. VAT)"
|
16
|
+
end
|
17
|
+
|
18
|
+
def tax_details(params)
|
19
|
+
"(including VAT at 15%)"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,212 @@
|
|
1
|
+
module Invoicing
|
2
|
+
# = Input and output of monetary values
|
3
|
+
#
|
4
|
+
# This module simplifies model objects which need to store monetary values. It automatically takes care
|
5
|
+
# of currency rounding conventions and formatting values for output.
|
6
|
+
#
|
7
|
+
# == General notes on currency precision and rounding
|
8
|
+
#
|
9
|
+
# It is important to deal carefully with rounding errors in accounts. If the figures don't add up exactly,
|
10
|
+
# you may have to pay for expensive accountant hours while they try to find out where the missing pennies or
|
11
|
+
# cents have gone -- better to avoid this trouble from the start. Because of this, it is strongly recommended
|
12
|
+
# that you use fixed-point or decimal datatypes to store any sort of currency amounts, never floating-point
|
13
|
+
# numbers.
|
14
|
+
#
|
15
|
+
# Keep in mind that not all currencies subdivide their main unit into 100 smaller units; storing four digits
|
16
|
+
# after the decimal point should be enough to allow you to expand into other currencies in future. Also leave
|
17
|
+
# enough headroom in case you ever need to use an inflated currency. For example,
|
18
|
+
# if you are using MySQL, <tt>decimal(20,4)</tt> may be a good choice for all your columns which store
|
19
|
+
# monetary amounts. The extra few bytes aren't going to cost you anything.
|
20
|
+
#
|
21
|
+
# On the other hand, it doesn't usually make sense to store monetary values with a higher precision than is
|
22
|
+
# conventional for a particular currency (usually this is related to the value of the smallest coin in
|
23
|
+
# circulation, but conventions may differ). For example, if your currency rounds to two decimal places, then
|
24
|
+
# you should also round every monetary amount to two decimal places before storing it. If you store values
|
25
|
+
# at a higher precision than you display, your numbers may appear to not add up correctly when you present
|
26
|
+
# them to users. Fortunately, this module automatically performs currency-specific rounding for you.
|
27
|
+
#
|
28
|
+
# == Using +acts_as_currency_value+
|
29
|
+
#
|
30
|
+
# This module simplifies model objects which need to store monetary values, by automatically taking care
|
31
|
+
# of currency rounding and formatting conventions. In a typical set-up, every model object which has one or
|
32
|
+
# more attributes storing monetary amounts (a price, a fee, a tax amount, a payment value, etc.) should also
|
33
|
+
# have a +currency+ column, which stores the ISO 4217 three-letter upper-case code identifying the currency.
|
34
|
+
# Annotate your model class with +acts_as_currency_value+, passing it a list of attribute names which store
|
35
|
+
# monetary amounts. If you refuse to store a +currency+ attribute, you may instead specify a default currency
|
36
|
+
# by passing a <tt>:currency_code => CODE</tt> option to +acts_as_currency_value+, but this is not recommended:
|
37
|
+
# even if you are only using one currency now, you may well expand into other currencies later. It is not
|
38
|
+
# possible to have multiple different currencies in the same model object.
|
39
|
+
#
|
40
|
+
# The +CurrencyValue+ module knows how to handle a set of default currencies (see +CURRENCIES+ below). If your
|
41
|
+
# currency is not supported in the way you want, you can extend/modify the hash yourself (please also send us
|
42
|
+
# a patch so that we can extend our list of inbuilt currencies):
|
43
|
+
# Invoicing::CurrencyValue::CURRENCIES['HKD'] = {:symbol => 'HK$', :round => 0.10, :digits => 2}
|
44
|
+
# This specifies that the Hong Kong Dollar should be displayed using the 'HK$' symbol and two digits after the
|
45
|
+
# decimal point, but should always be rounded to the nearest 10 cents since the 10 cent coin is the smallest
|
46
|
+
# in circulation (therefore the second digit after the decimal point will always be zero).
|
47
|
+
#
|
48
|
+
# When that is done, you can use the model object normally, and rounding will occur automatically:
|
49
|
+
# invoice.currency = 'HKD'
|
50
|
+
# invoice.tax_amount = invoice.net_amount * TaxRates.default_rate_now # 1234.56789
|
51
|
+
# invoice.tax_amount == BigDecimal('1234.6') # true - rounded to nearest 0.01
|
52
|
+
#
|
53
|
+
# Moreover, you can just append +_formatted+ to your attribute name and get the value formatted for including
|
54
|
+
# in your views:
|
55
|
+
# invoice.tax_amount_formatted # 'HK$1,234.60'
|
56
|
+
# The string returned by a +_formatted+ method is UTF-8 encoded -- remember most currency symbols (except $)
|
57
|
+
# are outside basic 7-bit ASCII.
|
58
|
+
module CurrencyValue
|
59
|
+
|
60
|
+
# Data about currencies, indexed by ISO 4217 code. (Currently a very short list, in need of extending.)
|
61
|
+
# The values are hashes, in which the following keys are recognised:
|
62
|
+
# <tt>:round</tt>:: Smallest unit of the currency in normal use, to which values are rounded. Default is 0.01.
|
63
|
+
# <tt>:symbol</tt>:: Symbol or string usually used to denote the currency. Encoded as UTF-8. Default is ISO 4217 code.
|
64
|
+
# <tt>:suffix</tt>:: +true+ if the currency symbol appears after the number, +false+ if it appears before. Default +false+.
|
65
|
+
CURRENCIES = {
|
66
|
+
'EUR' => {:symbol => "\xE2\x82\xAC"}, # Euro
|
67
|
+
'GBP' => {:symbol => "\xC2\xA3"}, # Pound Sterling
|
68
|
+
'USD' => {:symbol => "$"}, # United States Dollar
|
69
|
+
'CAD' => {:symbol => "$"}, # Canadian Dollar
|
70
|
+
'AUD' => {:symbol => "$"}, # Australian Dollar
|
71
|
+
'CNY' => {:symbol => "\xE5\x85\x83", :suffix => true}, # Chinese Yuan (RMB)
|
72
|
+
'INR' => {:symbol => "\xE2\x82\xA8"}, # Indian Rupee
|
73
|
+
'JPY' => {:symbol => "\xC2\xA5", :round => 1} # Japanese Yen
|
74
|
+
}
|
75
|
+
|
76
|
+
module ActMethods
|
77
|
+
# Declares that the current model object has columns storing monetary amounts. Pass those attribute
|
78
|
+
# names to +acts_as_currency_value+. By default, we try to find an attribute or method called +currency+,
|
79
|
+
# which stores the 3-letter ISO 4217 currency code for a record; if that attribute has a different name,
|
80
|
+
# specify the name using the <tt>:currency</tt> option. For example:
|
81
|
+
#
|
82
|
+
# class Price < ActiveRecord::Base
|
83
|
+
# validates_numericality_of :net_amount, :tax_amount
|
84
|
+
# validates_inclusion_of :currency_code, %w( USD GBP EUR JPY )
|
85
|
+
# acts_as_currency_value :net_amount, :tax_amount, :currency => :currency_code
|
86
|
+
# end
|
87
|
+
def acts_as_currency_value(*args)
|
88
|
+
Invoicing::ClassInfo.acts_as(Invoicing::CurrencyValue, self, args)
|
89
|
+
|
90
|
+
# Register callback if this is the first time acts_as_currency_value has been called
|
91
|
+
before_save :write_back_currency_values if currency_value_class_info.previous_info.nil?
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Format a numeric monetary value into a human-readable string, in the currency of the
|
96
|
+
# current model object.
|
97
|
+
def format_currency_value(value)
|
98
|
+
currency_value_class_info.format_value(self, value)
|
99
|
+
end
|
100
|
+
|
101
|
+
|
102
|
+
# Called automatically via +before_save+. Writes the result of converting +CurrencyValue+ attributes
|
103
|
+
# back to the actual attributes, so that they are saved in the database. (This doesn't happen in
|
104
|
+
# +convert_currency_values+ to avoid losing the +_before_type_cast+ attribute values.)
|
105
|
+
def write_back_currency_values
|
106
|
+
currency_value_class_info.all_args.each {|attr| write_attribute(attr, send(attr)) }
|
107
|
+
end
|
108
|
+
|
109
|
+
protected :write_back_currency_values
|
110
|
+
|
111
|
+
|
112
|
+
class ClassInfo < Invoicing::ClassInfo::Base #:nodoc:
|
113
|
+
|
114
|
+
def initialize(model_class, previous_info, args)
|
115
|
+
super
|
116
|
+
new_args.each{|attr| generate_attrs(attr)}
|
117
|
+
end
|
118
|
+
|
119
|
+
# Generates the getter and setter method for attribute +attr+.
|
120
|
+
def generate_attrs(attr)
|
121
|
+
model_class.class_eval do
|
122
|
+
define_method(attr) do
|
123
|
+
currency_info = currency_value_class_info.currency_info_for(self)
|
124
|
+
return read_attribute(attr) if currency_info.nil?
|
125
|
+
round_factor = BigDecimal(currency_info[:round].to_s)
|
126
|
+
|
127
|
+
value = currency_value_class_info.attr_conversion_input(self, attr)
|
128
|
+
value.nil? ? nil : (value / round_factor).round * round_factor
|
129
|
+
end
|
130
|
+
|
131
|
+
define_method("#{attr}=") do |new_value|
|
132
|
+
write_attribute(attr, new_value)
|
133
|
+
end
|
134
|
+
|
135
|
+
define_method("#{attr}_formatted") do
|
136
|
+
begin
|
137
|
+
format_currency_value(Kernel.Float(send("#{attr}_before_type_cast")))
|
138
|
+
rescue ArgumentError, TypeError
|
139
|
+
'' # if <attr>_before_type_cast could not be converted to float
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# Returns the value of the currency code column of +object+, if available; otherwise the
|
146
|
+
# default currency code (set by the <tt>:currency_code</tt> option), if available; +nil+ if all
|
147
|
+
# else fails.
|
148
|
+
def currency_of(object)
|
149
|
+
if object.attributes.has_key?(method(:currency)) || object.respond_to?(method(:currency))
|
150
|
+
get(object, :currency)
|
151
|
+
else
|
152
|
+
all_options[:currency_code]
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# Returns a hash of information about the currency used by model +object+. Contains the following keys:
|
157
|
+
# <tt>:code</tt>:: The ISO 4217 code of the currency.
|
158
|
+
# <tt>:round</tt>:: Smallest unit of the currency in normal use, to which values are rounded. Default is 0.01.
|
159
|
+
# <tt>:symbol</tt>:: Symbol or string usually used to denote the currency. Encoded as UTF-8. Default is ISO 4217 code.
|
160
|
+
# <tt>:suffix</tt>:: +true+ if the currency symbol appears after the number, +false+ if it appears before.
|
161
|
+
# <tt>:space</tt>:: Whether or not to leave a space between the number and the currency symbol.
|
162
|
+
# <tt>:digits</tt>:: Number of digits to display after the decimal point.
|
163
|
+
def currency_info_for(object)
|
164
|
+
valid_options = [:symbol, :round, :suffix, :space, :digits, :format]
|
165
|
+
code = currency_of(object)
|
166
|
+
info = {:code => code, :symbol => code, :round => 0.01, :suffix => nil, :space => nil, :digits => nil}
|
167
|
+
if ::Invoicing::CurrencyValue::CURRENCIES.has_key? code
|
168
|
+
info.update(::Invoicing::CurrencyValue::CURRENCIES[code])
|
169
|
+
end
|
170
|
+
all_options.each_pair {|key, value| info[key] = value if valid_options.include? key }
|
171
|
+
|
172
|
+
info[:suffix] = true if info[:suffix].nil? && (info[:code] == info[:symbol]) && !info[:code].nil?
|
173
|
+
info[:space] = true if info[:space].nil? && info[:suffix]
|
174
|
+
info[:digits] = -Math.log10(info[:round]).floor if info[:digits].nil?
|
175
|
+
info[:digits] = 0 if info[:digits] < 0
|
176
|
+
|
177
|
+
info
|
178
|
+
end
|
179
|
+
|
180
|
+
# Formats a numeric value as a nice currency string in UTF-8 encoding.
|
181
|
+
# +object+ is the model object carrying the value (used to determine the currency).
|
182
|
+
def format_value(object, value)
|
183
|
+
info = currency_info_for(object)
|
184
|
+
|
185
|
+
# FIXME: take locale into account
|
186
|
+
value = "%.#{info[:digits]}f" % value
|
187
|
+
while value.sub!(/(\d+)(\d\d\d)/,'\1,\2'); end
|
188
|
+
if info[:space]
|
189
|
+
info[:suffix] ? "#{value} #{info[:symbol]}" : "#{info[:symbol]} #{value}"
|
190
|
+
else
|
191
|
+
info[:suffix] ? "#{value}#{info[:symbol]}" : "#{info[:symbol]}#{value}"
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
# If other modules have registered callbacks for the event of reading a rounded attribute,
|
196
|
+
# they are executed here. +attr+ is the name of the attribute being read.
|
197
|
+
def attr_conversion_input(object, attr)
|
198
|
+
value = nil
|
199
|
+
|
200
|
+
if callback = all_options[:conversion_input]
|
201
|
+
value = object.send(callback, attr)
|
202
|
+
end
|
203
|
+
|
204
|
+
unless value
|
205
|
+
raw_value = object.read_attribute(attr)
|
206
|
+
value = BigDecimal.new(raw_value.to_s) unless raw_value.nil?
|
207
|
+
end
|
208
|
+
value
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
@@ -0,0 +1,193 @@
|
|
1
|
+
module Invoicing
|
2
|
+
# = Subclass-aware filtering by class methods
|
3
|
+
#
|
4
|
+
# Utility module which can be mixed into <tt>ActiveRecord::Base</tt> subclasses which use
|
5
|
+
# single table inheritance. It enables you to query the database for model objects based
|
6
|
+
# on static class properties without having to instantiate more model objects than necessary.
|
7
|
+
# Its methods should be used as class methods, so the module should be mixed in using +extend+.
|
8
|
+
#
|
9
|
+
# For example:
|
10
|
+
#
|
11
|
+
# class Product < ActiveRecord::Base
|
12
|
+
# extend Invoicing::FindSubclasses
|
13
|
+
# def self.needs_refrigeration; false; end
|
14
|
+
# end
|
15
|
+
#
|
16
|
+
# class Food < Product; end
|
17
|
+
# class Bread < Food; end
|
18
|
+
# class Yoghurt < Food
|
19
|
+
# def self.needs_refrigeration; true; end
|
20
|
+
# end
|
21
|
+
# class GreekYoghurt < Yoghurt; end
|
22
|
+
#
|
23
|
+
# class Drink < Product; end
|
24
|
+
# class SoftDrink < Drink; end
|
25
|
+
# class Smoothie < Drink
|
26
|
+
# def self.needs_refrigeration; true; end
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# So we know that all +Yoghurt+ and all +Smoothie+ objects need refrigeration (including subclasses
|
30
|
+
# of +Yoghurt+ and +Smoothly+, unless they override +needs_refrigeration+ again), and the others
|
31
|
+
# don't. This fact is defined through a class method and not stored in the database. It needn't
|
32
|
+
# necessarily be constant -- you could make +needs_refrigeration+ return +true+ or +false+
|
33
|
+
# depending on the current temperature, for example.
|
34
|
+
#
|
35
|
+
# Now assume that in your application you need to query all objects which need refrigeration
|
36
|
+
# (and maybe also satisfy some other conditions). Since the database knows nothing about
|
37
|
+
# +needs_refrigeration+, what you would have to do traditionally is to instantiate all objects
|
38
|
+
# and then to filter them yourself, i.e.
|
39
|
+
#
|
40
|
+
# Product.find(:all).select{|p| p.class.needs_refrigeration}
|
41
|
+
#
|
42
|
+
# However, if only a small proportion of your products needs refrigeration, this requires you to
|
43
|
+
# load many more objects than necessary, putting unnecessary load on your application. With the
|
44
|
+
# +FindSubclasses+ module you can let the database do the filtering instead:
|
45
|
+
#
|
46
|
+
# Product.find(:all, :conditions => {:needs_refrigeration => true})
|
47
|
+
#
|
48
|
+
# You could even define a named scope to do the same thing:
|
49
|
+
#
|
50
|
+
# class Product
|
51
|
+
# named_scope :refrigerated_products, :conditions => {:needs_refrigeration => true})
|
52
|
+
# end
|
53
|
+
#
|
54
|
+
# Much nicer! The condition looks precisely like a condition on a database table column, even
|
55
|
+
# though it actually refers to a class method. Under the hood, this query translates into:
|
56
|
+
#
|
57
|
+
# Product.find(:all, :conditions => {:type => ['Yoghurt', 'GreekYoghurt', 'Smoothie']})
|
58
|
+
#
|
59
|
+
# And of course you can combine it with normal conditions on database table columns. If there
|
60
|
+
# is a table column and a class method with the same name, +FindSublasses+ remains polite and lets
|
61
|
+
# the table column take precedence.
|
62
|
+
#
|
63
|
+
# == How it works
|
64
|
+
#
|
65
|
+
# +FindSubclasses+ relies on having a list of all subclasses of your single-table-inheritance
|
66
|
+
# base class; then, if you specify a condition with a key which has no corresponding database
|
67
|
+
# table column, +FindSubclasses+ will check all subclasses for the return value of a class
|
68
|
+
# method with that name, and search for the names of classes which match the condition.
|
69
|
+
#
|
70
|
+
# Purists of object-oriented programming will most likely find this appalling, and it's important
|
71
|
+
# to know the limitations. In Ruby, a class can be notified if it subclassed, by defining the
|
72
|
+
# <tt>Class#inherited</tt> method; we use this to gather together a list of subclasses. Of course,
|
73
|
+
# we won't necessarily know about every class in the world which may subclass our class; in
|
74
|
+
# particular, <tt>Class#inherited</tt> won't be called until that subclass is loaded.
|
75
|
+
#
|
76
|
+
# If you're including the Ruby files with the subclass definitions using +require+, we will learn
|
77
|
+
# about subclasses as soon as they are defined. However, if class loading is delayed until a
|
78
|
+
# class is first used (for example, <tt>ActiveSupport::Dependencies</tt> does this with model
|
79
|
+
# objects in Rails projects), we could run into a situation where we don't yet know about all
|
80
|
+
# subclasses used in a project at the point where we need to process a class method condition.
|
81
|
+
# This would cause us to omit some objects we should have found.
|
82
|
+
#
|
83
|
+
# To prevent this from happening, this module searches for all types of object currently stored
|
84
|
+
# in the table (along the lines of <tt>SELECT DISTINCT type FROM table_name</tt>), and makes sure
|
85
|
+
# all class names mentioned there are loaded before evaluating a class method condition. Note that
|
86
|
+
# this doesn't necessarily load all subclasses, but at least it loads those which currently have
|
87
|
+
# instances stored in the database, so we won't omit any objects when selecting from the table.
|
88
|
+
# There is still room for race conditions to occur, but otherwise it should be fine. If you want
|
89
|
+
# to be on the safe side you can ensure all subclasses are loaded when your application
|
90
|
+
# initialises -- but that's not completely DRY ;-)
|
91
|
+
module FindSubclasses
|
92
|
+
|
93
|
+
# Overrides <tt>ActiveRecord::Base.sanitize_sql_hash_for_conditions</tt> since this is the method
|
94
|
+
# used to transform a hash of conditions into an SQL query fragment. This overriding method
|
95
|
+
# searches for class method conditions in the hash and transforms them into a condition on the
|
96
|
+
# class name. All further work is delegated back to the superclass method.
|
97
|
+
#
|
98
|
+
# Condition formats are very similar to those accepted by +ActiveRecord+:
|
99
|
+
# {:my_class_method => 'value'} # known_subclasses.select{|cls| cls.my_class_method == 'value' }
|
100
|
+
# {:my_class_method => [1, 2]} # known_subclasses.select{|cls| [1, 2].include?(cls.my_class_method) }
|
101
|
+
# {:my_class_method => 3..6} # known_subclasses.select{|cls| (3..6).include?(cls.my_class_method) }
|
102
|
+
# {:my_class_method => true} # known_subclasses.select{|cls| cls.my_class_method }
|
103
|
+
# {:my_class_method => false} # known_subclasses.reject{|cls| cls.my_class_method }
|
104
|
+
def sanitize_sql_hash_for_conditions(attrs, table_name = quoted_table_name)
|
105
|
+
new_attrs = {}
|
106
|
+
|
107
|
+
attrs.each_pair do |attr, value|
|
108
|
+
attr = attr_base = attr.to_s
|
109
|
+
attr_table_name = table_name
|
110
|
+
|
111
|
+
# Extract table name from qualified attribute names
|
112
|
+
attr_table_name, attr_base = attr.split('.', 2) if attr.include?('.')
|
113
|
+
|
114
|
+
if columns_hash.include?(attr_base) || ![self.table_name, quoted_table_name].include?(attr_table_name)
|
115
|
+
new_attrs[attr] = value # Condition on a table column, or another table -- pass through unmodified
|
116
|
+
else
|
117
|
+
begin
|
118
|
+
matching_classes = select_matching_subclasses(attr_base, value)
|
119
|
+
new_attrs["#{self.table_name}.#{inheritance_column}"] = matching_classes.map{|cls| cls.name.to_s}
|
120
|
+
rescue NoMethodError
|
121
|
+
new_attrs[attr] = value # If the class method doesn't exist, fall back to passing condition through unmodified
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
super(new_attrs, table_name)
|
127
|
+
end
|
128
|
+
|
129
|
+
# Returns a list of those classes within +known_subclasses+ which match a condition
|
130
|
+
# <tt>method_name => value</tt>. May raise +NoMethodError+ if a class object does not
|
131
|
+
# respond to +method_name+.
|
132
|
+
def select_matching_subclasses(method_name, value, table = table_name, type_column = inheritance_column)
|
133
|
+
known_subclasses(table, type_column).select do |cls|
|
134
|
+
returned = cls.send(method_name)
|
135
|
+
(returned == value) or case value
|
136
|
+
when true then !!returned
|
137
|
+
when false then !returned
|
138
|
+
when Array, Range then value.include?(returned)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# Ruby callback which is invoked when a subclass is created. We use this to build a list of known
|
144
|
+
# subclasses.
|
145
|
+
def inherited(subclass)
|
146
|
+
remember_subclass subclass
|
147
|
+
super
|
148
|
+
end
|
149
|
+
|
150
|
+
# Add +subclass+ to the list of know subclasses of this class.
|
151
|
+
def remember_subclass(subclass)
|
152
|
+
@known_subclasses ||= [self]
|
153
|
+
@known_subclasses << subclass unless @known_subclasses.include? subclass
|
154
|
+
self.superclass.remember_subclass(subclass) if self.superclass.respond_to? :remember_subclass
|
155
|
+
end
|
156
|
+
|
157
|
+
# Return the list of all known subclasses of this class, if necessary checking the database for
|
158
|
+
# classes which have not yet been loaded.
|
159
|
+
def known_subclasses(table = table_name, type_column = inheritance_column)
|
160
|
+
load_all_subclasses_found_in_database(table, type_column)
|
161
|
+
@known_subclasses ||= [self]
|
162
|
+
end
|
163
|
+
|
164
|
+
private
|
165
|
+
# Query the database for all qualified class names found in the +type_column+ column
|
166
|
+
# (called +type+ by default), and check that classes of that name have been loaded by the Ruby
|
167
|
+
# interpreter. If a type name is encountered which cannot be loaded,
|
168
|
+
# <tt>ActiveRecord::SubclassNotFound</tt> is raised.
|
169
|
+
#
|
170
|
+
# TODO: Cache this somehow, to avoid querying for class names more often than necessary. It's not
|
171
|
+
# obvious though how to do this best -- a different Ruby instance may insert a row into the
|
172
|
+
# database with a type which is not yet loaded in this interpreter. Maybe reloading the list
|
173
|
+
# of types from the database every 30-60 seconds or so would be a compromise?
|
174
|
+
def load_all_subclasses_found_in_database(table = table_name, type_column = inheritance_column)
|
175
|
+
quoted_table_name = connection.quote_table_name(table)
|
176
|
+
quoted_inheritance_column = connection.quote_column_name(type_column)
|
177
|
+
query = "SELECT DISTINCT #{quoted_inheritance_column} FROM #{quoted_table_name}"
|
178
|
+
for subclass_name in connection.select_all(query).map{|record| record[type_column]}
|
179
|
+
unless subclass_name.blank? # empty string or nil means base class
|
180
|
+
begin
|
181
|
+
compute_type(subclass_name)
|
182
|
+
rescue NameError
|
183
|
+
raise ActiveRecord::SubclassNotFound, # Error message borrowed from ActiveRecord::Base
|
184
|
+
"The single-table inheritance mechanism failed to locate the subclass: '#{subclass_name}'. " +
|
185
|
+
"This error is raised because the column '#{type_column}' is reserved for storing the class in case of inheritance. " +
|
186
|
+
"Please rename this column if you didn't intend it to be used for storing the inheritance class " +
|
187
|
+
"or overwrite #{self.to_s}.inheritance_column to use another column for that information."
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|