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.
Files changed (62) hide show
  1. data/CHANGELOG +3 -0
  2. data/LICENSE +20 -0
  3. data/Manifest +60 -0
  4. data/README +48 -0
  5. data/Rakefile +75 -0
  6. data/invoicing.gemspec +41 -0
  7. data/lib/invoicing.rb +9 -0
  8. data/lib/invoicing/cached_record.rb +107 -0
  9. data/lib/invoicing/class_info.rb +187 -0
  10. data/lib/invoicing/connection_adapter_ext.rb +44 -0
  11. data/lib/invoicing/countries/uk.rb +24 -0
  12. data/lib/invoicing/currency_value.rb +212 -0
  13. data/lib/invoicing/find_subclasses.rb +193 -0
  14. data/lib/invoicing/ledger_item.rb +718 -0
  15. data/lib/invoicing/ledger_item/render_html.rb +515 -0
  16. data/lib/invoicing/ledger_item/render_ubl.rb +268 -0
  17. data/lib/invoicing/line_item.rb +246 -0
  18. data/lib/invoicing/price.rb +9 -0
  19. data/lib/invoicing/tax_rate.rb +9 -0
  20. data/lib/invoicing/taxable.rb +355 -0
  21. data/lib/invoicing/time_dependent.rb +388 -0
  22. data/lib/invoicing/version.rb +21 -0
  23. data/test/cached_record_test.rb +100 -0
  24. data/test/class_info_test.rb +253 -0
  25. data/test/connection_adapter_ext_test.rb +71 -0
  26. data/test/currency_value_test.rb +184 -0
  27. data/test/find_subclasses_test.rb +120 -0
  28. data/test/fixtures/README +7 -0
  29. data/test/fixtures/cached_record.sql +22 -0
  30. data/test/fixtures/class_info.sql +28 -0
  31. data/test/fixtures/currency_value.sql +29 -0
  32. data/test/fixtures/find_subclasses.sql +43 -0
  33. data/test/fixtures/ledger_item.sql +39 -0
  34. data/test/fixtures/line_item.sql +33 -0
  35. data/test/fixtures/price.sql +4 -0
  36. data/test/fixtures/tax_rate.sql +4 -0
  37. data/test/fixtures/taxable.sql +14 -0
  38. data/test/fixtures/time_dependent.sql +35 -0
  39. data/test/ledger_item_test.rb +352 -0
  40. data/test/line_item_test.rb +139 -0
  41. data/test/models/README +4 -0
  42. data/test/models/test_subclass_in_another_file.rb +3 -0
  43. data/test/models/test_subclass_not_in_database.rb +6 -0
  44. data/test/price_test.rb +9 -0
  45. data/test/ref-output/creditnote3.html +82 -0
  46. data/test/ref-output/creditnote3.xml +89 -0
  47. data/test/ref-output/invoice1.html +93 -0
  48. data/test/ref-output/invoice1.xml +111 -0
  49. data/test/ref-output/invoice2.html +86 -0
  50. data/test/ref-output/invoice2.xml +98 -0
  51. data/test/ref-output/invoice_null.html +36 -0
  52. data/test/render_html_test.rb +69 -0
  53. data/test/render_ubl_test.rb +32 -0
  54. data/test/setup.rb +37 -0
  55. data/test/tax_rate_test.rb +9 -0
  56. data/test/taxable_test.rb +180 -0
  57. data/test/test_helper.rb +48 -0
  58. data/test/time_dependent_test.rb +180 -0
  59. data/website/curvycorners.js +1 -0
  60. data/website/screen.css +149 -0
  61. data/website/template.html.erb +43 -0
  62. 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