invoicing 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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