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,3 @@
1
+ v0.1.0. Core API is now usable. RCov reports 100% test coverage.
2
+
3
+ v0.0.1. Initial public release
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Martin Kleppmann
2
+ Copyright (c) 2009 Ept Computing Limited
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
5
+ this software and associated documentation files (the "Software"), to deal in
6
+ the Software without restriction, including without limitation the rights to
7
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
8
+ of the Software, and to permit persons to whom the Software is furnished to do
9
+ so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
@@ -0,0 +1,60 @@
1
+ CHANGELOG
2
+ lib/invoicing/cached_record.rb
3
+ lib/invoicing/class_info.rb
4
+ lib/invoicing/connection_adapter_ext.rb
5
+ lib/invoicing/countries/uk.rb
6
+ lib/invoicing/currency_value.rb
7
+ lib/invoicing/find_subclasses.rb
8
+ lib/invoicing/ledger_item/render_html.rb
9
+ lib/invoicing/ledger_item/render_ubl.rb
10
+ lib/invoicing/ledger_item.rb
11
+ lib/invoicing/line_item.rb
12
+ lib/invoicing/price.rb
13
+ lib/invoicing/tax_rate.rb
14
+ lib/invoicing/taxable.rb
15
+ lib/invoicing/time_dependent.rb
16
+ lib/invoicing/version.rb
17
+ lib/invoicing.rb
18
+ LICENSE
19
+ Manifest
20
+ Rakefile
21
+ README
22
+ test/cached_record_test.rb
23
+ test/class_info_test.rb
24
+ test/connection_adapter_ext_test.rb
25
+ test/currency_value_test.rb
26
+ test/find_subclasses_test.rb
27
+ test/fixtures/cached_record.sql
28
+ test/fixtures/class_info.sql
29
+ test/fixtures/currency_value.sql
30
+ test/fixtures/find_subclasses.sql
31
+ test/fixtures/ledger_item.sql
32
+ test/fixtures/line_item.sql
33
+ test/fixtures/price.sql
34
+ test/fixtures/README
35
+ test/fixtures/tax_rate.sql
36
+ test/fixtures/taxable.sql
37
+ test/fixtures/time_dependent.sql
38
+ test/ledger_item_test.rb
39
+ test/line_item_test.rb
40
+ test/models/README
41
+ test/models/test_subclass_in_another_file.rb
42
+ test/models/test_subclass_not_in_database.rb
43
+ test/price_test.rb
44
+ test/ref-output/creditnote3.html
45
+ test/ref-output/creditnote3.xml
46
+ test/ref-output/invoice1.html
47
+ test/ref-output/invoice1.xml
48
+ test/ref-output/invoice2.html
49
+ test/ref-output/invoice2.xml
50
+ test/ref-output/invoice_null.html
51
+ test/render_html_test.rb
52
+ test/render_ubl_test.rb
53
+ test/setup.rb
54
+ test/tax_rate_test.rb
55
+ test/taxable_test.rb
56
+ test/test_helper.rb
57
+ test/time_dependent_test.rb
58
+ website/curvycorners.js
59
+ website/screen.css
60
+ website/template.html.erb
data/README ADDED
@@ -0,0 +1,48 @@
1
+ h1. Ruby invoicing framework
2
+
3
+ * "Homepage":http://invoicing.rubyforge.org/
4
+ * "API Reference Docs":http://invoicing.rubyforge.org/docs/invoicing/
5
+ * "RubyForge project":http://rubyforge.org/projects/invoicing/
6
+ * Email: Martin Kleppmann <ept@rubyforge.org>
7
+
8
+ h2. DESCRIPTION
9
+
10
+ Framework for generating and displaying invoices (ideal for commercial Rails
11
+ apps). Allows for flexible business logic; provides tools for tax handling,
12
+ commission calculation etc. Both developer-friendly and accountant-friendly.
13
+
14
+ The Ruby invoicing framework provides tools, helpers and a structure for
15
+ applications (particularly web apps) which need to generate invoices for
16
+ customers. It builds on ActiveRecord and is particularly suited for Rails
17
+ applications, but could be used with other frameworks too.
18
+
19
+ h2. FEATURES
20
+
21
+ * Dealing with tax rates changing over time
22
+ * Calculating commissions (e.g. in a reseller setting)
23
+
24
+ h2. SYNOPSIS
25
+
26
+ <pre syntax="ruby">
27
+ require 'invoicing'
28
+ </pre>
29
+
30
+ h2. STATUS
31
+
32
+ So far, the Ruby Invoicing Framework has been tested with ActiveRecord 2.2.2,
33
+ MySQL 5.0.67 and PostgreSQL 8.3.5. We will be testing it across a wider
34
+ variety of versions soon.
35
+
36
+ h2. CREDITS
37
+
38
+ The Ruby invoicing framework originated as part of the website Bid for Wine
39
+ (http://www.bidforwine.co.uk), developed by Patrick Dietrich, Conrad Irwin
40
+ and Michael Arnold for Ept Computing Ltd. It was extracted from the Bid for
41
+ Wine codebase and substantially extended by Martin Kleppmann.
42
+
43
+ h2. LICENSE
44
+
45
+ Copyright (c) 2009 Martin Kleppmann, Ept Computing Limited.
46
+
47
+ This gem is made publicly available under the terms of the MIT license.
48
+ See LICENSE and/or COPYING for details.
@@ -0,0 +1,75 @@
1
+ require 'rubygems'
2
+ require 'echoe'
3
+
4
+ # Add the project's top level directory and the lib directory to the Ruby search path
5
+ $: << File.expand_path(File.join(File.dirname(__FILE__), "lib"))
6
+ $: << File.expand_path(File.dirname(__FILE__))
7
+
8
+ require 'invoicing'
9
+
10
+ Echoe.new('invoicing', Invoicing::VERSION) do |p|
11
+ p.summary = 'Ruby invoicing framework'
12
+ p.description = 'Provides tools for applications which need to generate invoices for customers.'
13
+ p.url = 'http://invoicing.rubyforge.org/'
14
+ p.author = 'Martin Kleppmann'
15
+ p.email = 'rubyforge@eptcomputing.com'
16
+ p.dependencies = ['activerecord >=2.1.0', 'builder >= 2.0']
17
+ p.docs_host = 'ept@rubyforge.org:/var/www/gforge-projects/'
18
+ p.test_pattern = 'test/*_test.rb' # do not include test/models/*.rb
19
+ p.rcov_options = "-x '/Library/'"
20
+ end
21
+
22
+
23
+ desc "Generate a new website from README file"
24
+ task 'website' do
25
+ require 'rubygems'
26
+ require 'redcloth'
27
+ require 'syntax/convertors/html'
28
+ require 'erb'
29
+
30
+ version = Invoicing::VERSION
31
+ download = 'http://rubyforge.org/projects/invoicing'
32
+
33
+ def convert_syntax(syntax, source)
34
+ return Syntax::Convertors::HTML.for_syntax(syntax).convert(source).gsub(%r!^<pre>|</pre>$!,'')
35
+ end
36
+
37
+ template = ERB.new(File.open(File.join(File.dirname(__FILE__), '/website/template.html.erb')).read)
38
+
39
+ title = nil
40
+ body = nil
41
+ File.open(File.join(File.dirname(__FILE__), '/README')) do |fsrc|
42
+ title = fsrc.readline.gsub(/^[^ ]* /, '')
43
+ body_text = fsrc.read
44
+ syntax_items = []
45
+ body_text.gsub!(%r!<(pre|code)[^>]*?syntax=['"]([^'"]+)[^>]*>(.*?)</\1>!m){
46
+ ident = syntax_items.length
47
+ element, syntax, source = $1, $2, $3
48
+ syntax_items << "<#{element} class='syntax'>#{convert_syntax(syntax, source)}</#{element}>"
49
+ "syntax-temp-#{ident}"
50
+ }
51
+ body = RedCloth.new(body_text).to_html
52
+ body.gsub!(%r!(?:<pre><code>)?syntax-temp-(\d+)(?:</code></pre>)?!){ syntax_items[$1.to_i] }
53
+ end
54
+
55
+ File.open(File.join(File.dirname(__FILE__), '/website/index.html'), 'w') do |fout|
56
+ fout.write(template.result(binding))
57
+ end
58
+ end
59
+
60
+
61
+ desc "Generate and publish website"
62
+ task :publish_website => :website do
63
+ require 'net/sftp'
64
+ upload_user = 'ept'
65
+ upload_host = 'rubyforge.org'
66
+ upload_dir = '/var/www/gforge-projects/invoicing'
67
+ local_dir = File.join(File.dirname(__FILE__), 'website')
68
+ Net::SFTP.start(upload_host, upload_user) do |sftp|
69
+ for f in Dir.entries(local_dir)
70
+ next if f =~ /^\./
71
+ puts "Uploading #{f} to #{upload_user}@#{upload_host}:#{upload_dir}/#{f}"
72
+ sftp.upload!(File.join(local_dir, f), "#{upload_dir}/#{f}")
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,41 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = %q{invoicing}
5
+ s.version = "0.1.0"
6
+
7
+ s.required_rubygems_version = Gem::Requirement.new(">= 1.2") if s.respond_to? :required_rubygems_version=
8
+ s.authors = ["Martin Kleppmann"]
9
+ s.date = %q{2009-02-10}
10
+ s.description = %q{Provides tools for applications which need to generate invoices for customers.}
11
+ s.email = %q{rubyforge@eptcomputing.com}
12
+ s.extra_rdoc_files = ["CHANGELOG", "lib/invoicing/cached_record.rb", "lib/invoicing/class_info.rb", "lib/invoicing/connection_adapter_ext.rb", "lib/invoicing/countries/uk.rb", "lib/invoicing/currency_value.rb", "lib/invoicing/find_subclasses.rb", "lib/invoicing/ledger_item/render_html.rb", "lib/invoicing/ledger_item/render_ubl.rb", "lib/invoicing/ledger_item.rb", "lib/invoicing/line_item.rb", "lib/invoicing/price.rb", "lib/invoicing/tax_rate.rb", "lib/invoicing/taxable.rb", "lib/invoicing/time_dependent.rb", "lib/invoicing/version.rb", "lib/invoicing.rb", "LICENSE", "README"]
13
+ s.files = ["CHANGELOG", "lib/invoicing/cached_record.rb", "lib/invoicing/class_info.rb", "lib/invoicing/connection_adapter_ext.rb", "lib/invoicing/countries/uk.rb", "lib/invoicing/currency_value.rb", "lib/invoicing/find_subclasses.rb", "lib/invoicing/ledger_item/render_html.rb", "lib/invoicing/ledger_item/render_ubl.rb", "lib/invoicing/ledger_item.rb", "lib/invoicing/line_item.rb", "lib/invoicing/price.rb", "lib/invoicing/tax_rate.rb", "lib/invoicing/taxable.rb", "lib/invoicing/time_dependent.rb", "lib/invoicing/version.rb", "lib/invoicing.rb", "LICENSE", "Manifest", "Rakefile", "README", "test/cached_record_test.rb", "test/class_info_test.rb", "test/connection_adapter_ext_test.rb", "test/currency_value_test.rb", "test/find_subclasses_test.rb", "test/fixtures/cached_record.sql", "test/fixtures/class_info.sql", "test/fixtures/currency_value.sql", "test/fixtures/find_subclasses.sql", "test/fixtures/ledger_item.sql", "test/fixtures/line_item.sql", "test/fixtures/price.sql", "test/fixtures/README", "test/fixtures/tax_rate.sql", "test/fixtures/taxable.sql", "test/fixtures/time_dependent.sql", "test/ledger_item_test.rb", "test/line_item_test.rb", "test/models/README", "test/models/test_subclass_in_another_file.rb", "test/models/test_subclass_not_in_database.rb", "test/price_test.rb", "test/ref-output/creditnote3.html", "test/ref-output/creditnote3.xml", "test/ref-output/invoice1.html", "test/ref-output/invoice1.xml", "test/ref-output/invoice2.html", "test/ref-output/invoice2.xml", "test/ref-output/invoice_null.html", "test/render_html_test.rb", "test/render_ubl_test.rb", "test/setup.rb", "test/tax_rate_test.rb", "test/taxable_test.rb", "test/test_helper.rb", "test/time_dependent_test.rb", "website/curvycorners.js", "website/screen.css", "website/template.html.erb", "invoicing.gemspec"]
14
+ s.has_rdoc = true
15
+ s.homepage = %q{http://invoicing.rubyforge.org/}
16
+ s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "Invoicing", "--main", "README"]
17
+ s.require_paths = ["lib"]
18
+ s.rubyforge_project = %q{invoicing}
19
+ s.rubygems_version = %q{1.3.1}
20
+ s.summary = %q{Ruby invoicing framework}
21
+ s.test_files = ["test/cached_record_test.rb", "test/class_info_test.rb", "test/connection_adapter_ext_test.rb", "test/currency_value_test.rb", "test/find_subclasses_test.rb", "test/ledger_item_test.rb", "test/line_item_test.rb", "test/price_test.rb", "test/render_html_test.rb", "test/render_ubl_test.rb", "test/tax_rate_test.rb", "test/taxable_test.rb", "test/time_dependent_test.rb"]
22
+
23
+ if s.respond_to? :specification_version then
24
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
25
+ s.specification_version = 2
26
+
27
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
28
+ s.add_runtime_dependency(%q<activerecord>, [">= 2.1.0"])
29
+ s.add_runtime_dependency(%q<builder>, [">= 0", "= 2.0"])
30
+ s.add_development_dependency(%q<echoe>, [">= 0"])
31
+ else
32
+ s.add_dependency(%q<activerecord>, [">= 2.1.0"])
33
+ s.add_dependency(%q<builder>, [">= 0", "= 2.0"])
34
+ s.add_dependency(%q<echoe>, [">= 0"])
35
+ end
36
+ else
37
+ s.add_dependency(%q<activerecord>, [">= 2.1.0"])
38
+ s.add_dependency(%q<builder>, [">= 0", "= 2.0"])
39
+ s.add_dependency(%q<echoe>, [">= 0"])
40
+ end
41
+ end
@@ -0,0 +1,9 @@
1
+ require 'activerecord'
2
+
3
+ require 'invoicing/class_info' # load first because other modules depend on this
4
+ Dir.glob(File.join(File.dirname(__FILE__), 'invoicing/**/*.rb')).sort.each {|f| require f }
5
+
6
+ # Mix all modules Invoicing::*::ActMethods into ActiveRecord::Base as class methods
7
+ Invoicing.constants.map{|c| Invoicing.const_get(c) }.select{|m| m.is_a?(Module) && m.const_defined?('ActMethods') }.each{
8
+ |m| ActiveRecord::Base.send(:extend, m.const_get('ActMethods'))
9
+ }
@@ -0,0 +1,107 @@
1
+ module Invoicing
2
+ # == Aggressive ActiveRecord cache
3
+ #
4
+ # This module implements a cache of +ActiveRecord+ objects. It is suitable for database
5
+ # tables with a small number of rows (no more than a few dozen is recommended) which
6
+ # change very infrequently. The contents of the table is loaded into memory when the
7
+ # class is first created; <b>to clear the cache you must call +clear_cache+ or
8
+ # restart the Ruby interpreter</b>. It is recommended that if you need to change the
9
+ # data in this table, you do so in a database migration, and apply that migration as
10
+ # part of a release deployment.
11
+ #
12
+ # The cache works as a simple identity map: it has a hash where the key is the primary
13
+ # key of each model object and the value is the model object itself. +ActiveRecord+
14
+ # methods are overridden so that if +find+ is called with one or more IDs, the object(s)
15
+ # are returned from cache; if +find+ is called with more complex conditions, the usual
16
+ # database mechanisms are used and the cache is ignored. Note that this does not
17
+ # guarantee that the same ID value will always map to the same model object instance;
18
+ # it just reduces the number of database queries.
19
+ #
20
+ # To activate +CachedRecord+, call +acts_as_cached_record+ in the scope of an
21
+ # <tt>ActiveRecord::Base</tt> class.
22
+ module CachedRecord
23
+
24
+ module ActMethods
25
+ # Call +acts_as_cached_record+ on an <tt>ActiveRecord::Base</tt> class to declare
26
+ # that objects of this class should be cached using +CachedRecord+.
27
+ #
28
+ # Accepts options in a hash, all of which are optional:
29
+ # * +id+ -- If the primary key of this model is not +id+, declare the method name
30
+ # of the primary key.
31
+ def acts_as_cached_record(*args)
32
+ Invoicing::ClassInfo.acts_as(Invoicing::CachedRecord, self, args)
33
+ end
34
+ end
35
+
36
+ module ClassMethods
37
+ # This method overrides the default <tt>ActiveRecord::Base.find_from_ids</tt> (which is called
38
+ # from <tt>ActiveRecord::Base.find</tt>) with caching behaviour. +find+ is also used by
39
+ # +ActiveRecord+ when evaluating associations; therefore if another model object refers to
40
+ # a cached record by its ID, calling the getter of that association should result in a cache hit.
41
+ #
42
+ # FIXME: Currently +options+ is ignored -- we should do something more useful with it
43
+ # to ensure CachedRecord behaviour is fully compatible with +ActiveRecord+.
44
+ def find_from_ids(ids, options)
45
+ expects_array = ids.first.kind_of?(Array)
46
+ return ids.first if expects_array && ids.first.empty?
47
+
48
+ ids = ids.flatten.compact.uniq
49
+
50
+ case ids.size
51
+ when 0
52
+ raise ::ActiveRecord::RecordNotFound, "Couldn't find #{name} without an ID"
53
+ when 1
54
+ result = cached_record_class_info.find_one(ids.first, options)
55
+ expects_array ? [ result ] : result
56
+ else
57
+ cached_record_class_info.find_some(ids, options)
58
+ end
59
+ end
60
+
61
+ # Returns a list of all objects of this class. Like <tt>ActiveRecord::Base.find(:all)</tt>
62
+ # but coming from the cache.
63
+ def cached_record_list
64
+ cached_record_class_info.list
65
+ end
66
+
67
+ # Reloads the cached objects from the database.
68
+ def reload_cache
69
+ cached_record_class_info.reload_cache
70
+ end
71
+ end # module ClassMethods
72
+
73
+
74
+ # Stores state in the ActiveRecord class object, including the cache --
75
+ # a hash which maps ID to model object for all objects of this model object type
76
+ class ClassInfo < Invoicing::ClassInfo::Base #:nodoc:
77
+ def initialize(model_class, previous_info, args)
78
+ super
79
+ reload_cache
80
+ end
81
+
82
+ def reload_cache
83
+ @cache = {}
84
+ model_class.find(:all).each {|obj| @cache[get(obj, :id)] = obj }
85
+ end
86
+
87
+ # Returns one object from the cache, given its ID.
88
+ def find_one(id, options)
89
+ if result = @cache[id]
90
+ result
91
+ else
92
+ raise ::ActiveRecord::RecordNotFound, "Couldn't find #{model_class.name} with ID=#{id}"
93
+ end
94
+ end
95
+
96
+ # Returns a list of objects from the cache, given a list of IDs.
97
+ def find_some(ids, options)
98
+ ids.map{|id| find_one(id, options) }
99
+ end
100
+
101
+ # Returns a list of all objects in the cache.
102
+ def list
103
+ @cache.values
104
+ end
105
+ end # class ClassInfo
106
+ end # module CachedRecord
107
+ end
@@ -0,0 +1,187 @@
1
+ module Invoicing
2
+ # This module is intended for use only internally within this framework. It implements
3
+ # a pattern needed in several other modules: an +acts_as_something_or_other+ method can
4
+ # be called within the scope of an +ActiveRecord+ class, given a number of arguments;
5
+ # including options which define how columns are renamed in a given model object.
6
+ # The information from these arguments needs to be stored in a class variable for later
7
+ # use in instances of that class. It must be possible to call the +acts_as_+ method
8
+ # multiple times, combining the arguments from the various calls, to make the whole thing
9
+ # look nicely declarative. Subclasses should inherit +acts_as_+ arguments from their
10
+ # superclass, but should be able to override them with their own values.
11
+ #
12
+ # This pattern assumes a particular module structure, like the following:
13
+ #
14
+ # module MyNamespace # you may use arbitrarily nested modules for namespacing (optional)
15
+ # module Teleporter # the name of this module defines auto-generated method names
16
+ # module ActMethods
17
+ # def acts_as_teleporter(*args) # should be called "acts_as_#{module_name.underscore}"
18
+ # Invoicing::ClassInfo.acts_as(MyNamespace::Teleporter, self, args)
19
+ # end
20
+ # end
21
+ #
22
+ # def transmogrify_the_instance # will become an instance method of the class on which the
23
+ # info = teleporter_class_info # acts_as_ method is called.
24
+ # info.do_transmogrify
25
+ # end
26
+ #
27
+ # module ClassMethods
28
+ # def transmogrify_the_class # will become a class method of the class on which the
29
+ # info = teleporter_class_info # acts_as_ method is called.
30
+ # info.do_transmogrify
31
+ # end
32
+ # end
33
+ #
34
+ # class ClassInfo < Invoicing::ClassInfo::Base
35
+ # def do_transmogrify
36
+ # case all_options[:transmogrification]
37
+ # when :total then "Transmogrified by #{all_args.first}"
38
+ # end
39
+ # end
40
+ # end
41
+ # end
42
+ # end
43
+ #
44
+ # ActiveRecord::Base.send(:extend, MyNamespace::Teleporter::ActMethods)
45
+ #
46
+ #
47
+ # +ClassInfo+ is used to store and process the arguments passed to the +acts_as_teleporter+ method
48
+ # when it is called in the scope of an +ActiveRecord+ model class. Finally, the feature defined by
49
+ # the +Teleporter+ module above can be used like this:
50
+ #
51
+ # class Teleporter < ActiveRecord::Base
52
+ # acts_as_teleporter 'Zoom2020', :transmogrification => :total
53
+ # end
54
+ #
55
+ # Teleporter.transmogrify_the_class # both return "Transmogrified by Zoom2020"
56
+ # Teleporter.find(42).transmogrify_the_instance
57
+ module ClassInfo
58
+
59
+ # Provides the main implementation pattern for an +acts_as_+ method. See the example above
60
+ # for usage.
61
+ # +source_module+:: The module object which is using the +ClassInfo+ pattern
62
+ # +calling_class+:: The class in whose scope the +acts_as_+ method was called
63
+ # +args+:: The array of arguments (including options hash) to the +acts_as_+ method
64
+ def self.acts_as(source_module, calling_class, args)
65
+ # The name by which the particular module using ClassInfo is known
66
+ module_name = source_module.name.split('::').last.underscore
67
+ class_info_method = "#{module_name}_class_info"
68
+
69
+ previous_info = if calling_class.private_instance_methods(true).include?(class_info_method)
70
+ # acts_as has been called before on the same class, or a superclass
71
+ calling_class.send(class_info_method)
72
+ else
73
+ # acts_as is being called for the first time -- do the mixins!
74
+ calling_class.send(:include, source_module)
75
+ calling_class.send(:extend, source_module.const_get('ClassMethods')) if source_module.constants.include? 'ClassMethods'
76
+ nil # no previous_info
77
+ end
78
+
79
+ # Instantiate the ClassInfo::Base subclass and assign it to an instance variable in calling_class
80
+ class_info_class = source_module.const_get('ClassInfo')
81
+ class_info = class_info_class.new(calling_class, previous_info, args)
82
+ calling_class.instance_variable_set("@#{class_info_method}", class_info)
83
+
84
+ # Define a getter class method on calling_class through which the ClassInfo::Base
85
+ # instance can be accessed.
86
+ calling_class.class_eval <<-CLASSEVAL
87
+ class << self
88
+ def #{class_info_method}
89
+ if superclass.private_instance_methods(true).include?("#{class_info_method}")
90
+ @#{class_info_method} ||= superclass.send("#{class_info_method}")
91
+ end
92
+ @#{class_info_method}
93
+ end
94
+ private "#{class_info_method}"
95
+ end
96
+ CLASSEVAL
97
+
98
+ # For convenience, also define an instance method which does the same as the class method
99
+ calling_class.class_eval do
100
+ define_method class_info_method do
101
+ self.class.send(class_info_method)
102
+ end
103
+ private class_info_method
104
+ end
105
+ end
106
+
107
+
108
+ # Base class for +ClassInfo+ objects, from which you need to derive a subclass in each module where
109
+ # you want to use +ClassInfo+. An instance of a <tt>ClassInfo::Base</tt> subclass is created every
110
+ # time an +acts_as_+ method is called, and that instance can be accessed through the
111
+ # +my_module_name_class_info+ method on the class which called +acts_as_my_module_name+.
112
+ class Base
113
+ # The class on which the +acts_as_+ method was called
114
+ attr_reader :model_class
115
+
116
+ # The <tt>ClassInfo::Base</tt> instance created by the last +acts_as_+ method
117
+ # call on the same class (or its superclass); +nil+ if this is the first call.
118
+ attr_reader :previous_info
119
+
120
+ # The list of arguments passed to the current +acts_as_+ method call (excluding the final options hash)
121
+ attr_reader :current_args
122
+
123
+ # Union of +current_args+ and <tt>previous_info.all_args</tt>
124
+ attr_reader :all_args
125
+
126
+ # <tt>self.all_args - previous_info.all_args</tt>
127
+ attr_reader :new_args
128
+
129
+ # The options hash passed to the current +acts_as_+ method call
130
+ attr_reader :current_options
131
+
132
+ # Hash of options with symbolized keys, with +option_defaults+ overridden by +previous_info+ options,
133
+ # in turn overridden by +current_options+.
134
+ attr_reader :all_options
135
+
136
+ # Initialises a <tt>ClassInfo::Base</tt> instance and parses arguments.
137
+ # If subclasses override +initialize+ they should call +super+.
138
+ # +model_class+:: The class on which the +acts_as+ method was called
139
+ # +previous_info+:: The <tt>ClassInfo::Base</tt> instance created by the last +acts_as_+ method
140
+ # call on the same class (or its superclass); +nil+ if this is the first call.
141
+ # +args+:: Array of arguments given to the +acts_as_+ method when it was invoked.
142
+ #
143
+ # If the last element of +args+ is a hash, it is used as an options array. All other elements
144
+ # of +args+ are concatenated into an array, +uniq+ed and flattened. (They could be a list of symbols
145
+ # representing method names, for example.)
146
+ def initialize(model_class, previous_info, args)
147
+ @model_class = model_class
148
+ @previous_info = previous_info
149
+
150
+ @current_options = args.extract_options!.symbolize_keys
151
+ @all_options = (@previous_info.nil? ? option_defaults : @previous_info.all_options).clone
152
+ @all_options.update(@current_options)
153
+
154
+ @all_args = @new_args = @current_args = args.flatten.uniq
155
+ unless @previous_info.nil?
156
+ @all_args = (@previous_info.all_args + @all_args).uniq
157
+ @new_args = @all_args - previous_info.all_args
158
+ end
159
+ end
160
+
161
+ # Override this method to return a hash of default option values.
162
+ def option_defaults
163
+ {}
164
+ end
165
+
166
+ # If there is an option with the given key, returns the associated value; otherwise returns
167
+ # the key. This is useful for mapping method names to their renamed equivalents through options.
168
+ def method(name)
169
+ name = name.to_sym
170
+ (all_options[name] || name).to_s
171
+ end
172
+
173
+ # Returns the value returned by calling +method_name+ (renamed through options using +method+)
174
+ # on +object+. Returns +nil+ if +object+ is +nil+ or +object+ does not respond to that method.
175
+ def get(object, method_name)
176
+ meth = method(method_name)
177
+ (object.nil? || !object.respond_to?(meth)) ? nil : object.send(meth)
178
+ end
179
+
180
+ # Assigns +new_value+ to <tt>method_name=</tt> (renamed through options using +method+)
181
+ # on +object+. +method_name+ should not include the equals sign.
182
+ def set(object, method_name, new_value)
183
+ object.send("#{method(method_name)}=", new_value) unless object.nil?
184
+ end
185
+ end
186
+ end
187
+ end