invoicing 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|