acts_as_scd 0.0.1

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 (57) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.rdoc +43 -0
  4. data/Rakefile +32 -0
  5. data/lib/acts_as_scd/base_class_methods.rb +101 -0
  6. data/lib/acts_as_scd/block_updater.rb +142 -0
  7. data/lib/acts_as_scd/class_methods.rb +240 -0
  8. data/lib/acts_as_scd/initialize.rb +114 -0
  9. data/lib/acts_as_scd/instance_methods.rb +105 -0
  10. data/lib/acts_as_scd/period.rb +135 -0
  11. data/lib/acts_as_scd/version.rb +3 -0
  12. data/lib/acts_as_scd.rb +31 -0
  13. data/lib/tasks/acts_as_scd_tasks.rake +4 -0
  14. data/test/acts_as_scd_test.rb +680 -0
  15. data/test/dummy/README.rdoc +28 -0
  16. data/test/dummy/Rakefile +6 -0
  17. data/test/dummy/app/assets/javascripts/application.js +13 -0
  18. data/test/dummy/app/assets/stylesheets/application.css +15 -0
  19. data/test/dummy/app/controllers/application_controller.rb +5 -0
  20. data/test/dummy/app/helpers/application_helper.rb +2 -0
  21. data/test/dummy/app/models/association.rb +9 -0
  22. data/test/dummy/app/models/city.rb +19 -0
  23. data/test/dummy/app/models/commercial_delegate.rb +9 -0
  24. data/test/dummy/app/models/country.rb +29 -0
  25. data/test/dummy/app/views/layouts/application.html.erb +14 -0
  26. data/test/dummy/bin/bundle +3 -0
  27. data/test/dummy/bin/rails +4 -0
  28. data/test/dummy/bin/rake +4 -0
  29. data/test/dummy/config/application.rb +23 -0
  30. data/test/dummy/config/boot.rb +5 -0
  31. data/test/dummy/config/database.yml +25 -0
  32. data/test/dummy/config/environment.rb +5 -0
  33. data/test/dummy/config/environments/development.rb +37 -0
  34. data/test/dummy/config/environments/production.rb +83 -0
  35. data/test/dummy/config/environments/test.rb +41 -0
  36. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  37. data/test/dummy/config/initializers/cookies_serializer.rb +3 -0
  38. data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  39. data/test/dummy/config/initializers/inflections.rb +16 -0
  40. data/test/dummy/config/initializers/mime_types.rb +4 -0
  41. data/test/dummy/config/initializers/session_store.rb +3 -0
  42. data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
  43. data/test/dummy/config/locales/en.yml +23 -0
  44. data/test/dummy/config/locales/scd.en.yml +7 -0
  45. data/test/dummy/config/routes.rb +56 -0
  46. data/test/dummy/config/secrets.yml +22 -0
  47. data/test/dummy/config.ru +4 -0
  48. data/test/dummy/db/test.sqlite3 +0 -0
  49. data/test/dummy/log/test.log +24444 -0
  50. data/test/dummy/public/404.html +67 -0
  51. data/test/dummy/public/422.html +67 -0
  52. data/test/dummy/public/500.html +66 -0
  53. data/test/dummy/public/favicon.ico +0 -0
  54. data/test/fixtures/cities.yml +95 -0
  55. data/test/fixtures/countries.yml +83 -0
  56. data/test/test_helper.rb +15 -0
  57. metadata +185 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: cd88d8b2f1369a9cfb0e892bbc2fe3dd9e2d68ad
4
+ data.tar.gz: a27d5a9ee397392427b499c54793e4626ce43e1b
5
+ SHA512:
6
+ metadata.gz: f290ece490f8f573b3ec3695de9e523e559cc35b733dd135be492bc7da5d613e2dd9ae4ccec11a1290d97aba0c12f4f2557c768728b4285993179642c8ebc579
7
+ data.tar.gz: 45795c69e470ca3d6340b444518310b5264496c5084d329d1a6f1f1395146016a99dc71a1aab2a2d0fc28148cbdd0fd11e7c5665c0ce078310a840cef9cb2f18
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2014 YOURNAME
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,43 @@
1
+ = Acts as SCD (Slowly Changing Dimension)
2
+
3
+ This gem provides SCD behaviour for ActiveRecord models.
4
+ The kind of SCD implemented is "Type 2" acording to http://en.wikipedia.org/wiki/Slowly_changing_dimension
5
+
6
+ Models that use this plugin must provide an +identity+ column that establishes the identity
7
+ of an entity that may be representented by multiple records (iterations), each one of them
8
+ representing the entity for a specific 'effective' period of time.
9
+
10
+ The effective period of an iteration is defined with day-granularity by two integer columns,
11
+ +effective_from+ and +effective_to+ using YYYYMMDD format.
12
+ By default effective_from has value 0 and effective_to 99999999; these special values meaning
13
+ unlimited periods of time (0 represents the 'start of time' and 99999999 the 'end of time').
14
+
15
+ SCD models must provide a +compute_identity+ method to compute the identity attribute.
16
+
17
+ Here we use this terms:
18
+
19
+ * *Identity*: key that identifies an entity of a SCD through time. The tables will have
20
+ additional surrogate primary keys (id by default) that identify each iteration of
21
+ the entity. Here we'll use identity often in a loose sense to refer to the entity which
22
+ it identifies.
23
+ * *Iteration*: the different revisions or variations over time that an Identity may go through.
24
+ Each iteration of an identity has an effective period in which the iteration is the valid
25
+ representation of the identity. Here this period is specified by start and end dates (so that
26
+ variations which have any frequency higher than daily cannot be handled by this method)
27
+
28
+ Note that the +current+ term, when applied to identities, is used to mean the last iteration
29
+ if it extends indefinitely to the future (effective_to == 99999999). The last iteration may
30
+ have a effective end date, meaning that it has disappeared at that date, and in that case it
31
+ would not be current. Also, if iterations exist with effective dates in the future, the
32
+ current iterations may not be active at the current date. To get the iteration which is active
33
+ at the current or any other date, the +at+ methods should be used.
34
+
35
+ = TODO
36
+
37
+ * Write Tests
38
+ * Write Documentation
39
+ * Require modal_fields or make it optional?
40
+ * Create generator to add identity to a model and generate migration
41
+ * Release gem 1.0.0
42
+ * Test with Rails 3 & 4
43
+
data/Rakefile ADDED
@@ -0,0 +1,32 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'ActsAsScd'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.rdoc')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+
18
+
19
+
20
+ Bundler::GemHelper.install_tasks
21
+
22
+ require 'rake/testtask'
23
+
24
+ Rake::TestTask.new(:test) do |t|
25
+ t.libs << 'lib'
26
+ t.libs << 'test'
27
+ t.pattern = 'test/**/*_test.rb'
28
+ t.verbose = false
29
+ end
30
+
31
+
32
+ task default: :test
@@ -0,0 +1,101 @@
1
+ module ActsAsScd
2
+
3
+
4
+ module BaseClassMethods
5
+
6
+ def acts_as_scd(*args)
7
+ @slowly_changing_columns ||= []
8
+ @slowly_changing_indices ||= []
9
+ @slowly_changing_columns += [[IDENTITY_COLUMN, args], [START_COLUMN, [:integer, :default=>START_OF_TIME]], [END_COLUMN, [:integer, :default=>END_OF_TIME]]]
10
+ @slowly_changing_indices += [IDENTITY_COLUMN, START_COLUMN, END_COLUMN, [START_COLUMN, END_COLUMN]]
11
+ include ActsAsScd
12
+ end
13
+
14
+ def has_identity(*args)
15
+ acts_as_scd *args
16
+ if defined?(ModalFields) && respond_to?(:fields)
17
+ fields do
18
+ identity *args
19
+ effective_from :integer, :default=>START_OF_TIME
20
+ effective_to :integer, :default=>END_OF_TIME
21
+ end
22
+ end
23
+ end
24
+
25
+ # Association to be used in a child which belongs to a parent which has identity
26
+ # (the identity is used for the association rather than the id).
27
+ # The inverse assocation should be has_many_through_identity.
28
+ def belongs_to_identity(assoc, options={})
29
+ other_model = assoc.to_s.camelize.constantize
30
+ fk = :"#{other_model.model_name.to_s.underscore}_identity"
31
+ if defined?(@slowly_changing_columns)
32
+ @slowly_changing_columns << [fk, other_model.identity_column_definition.last]
33
+ @slowly_changing_indices << fk
34
+ end
35
+ belongs_to assoc, ->{ where "#{other_model.effective_to_column_sql()}=#{END_OF_TIME}" },
36
+ options.reverse_merge(foreign_key: fk, primary_key: IDENTITY_COLUMN)
37
+ # For Rails 3 is this necessary?:
38
+ # belongs_to assoc, {:foreign_key=>fk, :primary_key=>IDENTITY_COLUMN, :conditions=>"#{other_model.effective_to_column_sql()}=#{END_OF_TIME}"}.merge(options)
39
+ define_method :"#{assoc}_at" do |date=nil|
40
+ other_model.at(date).where(IDENTITY_COLUMN=>send(fk)).first
41
+ end
42
+ end
43
+
44
+ # Association to be used in a parent class which has children which have identities
45
+ # (the parent class is referenced by id and may not have identity)
46
+ # The inverse association should be belongs_to
47
+ def has_many_identities(assoc, options)
48
+ fk = options[:foreign_key] || :"#{model_name.to_s.underscore}_id"
49
+ pk = primary_key
50
+ other_model_name = options[:class_name] || assoc.to_s.singularize.camelize
51
+ other_model = other_model_name.to_s.constantize
52
+
53
+ # all children iterations
54
+ has_many :"#{assoc}_iterations", class_name: other_model_name, foreign_key: fk
55
+
56
+ # current children:
57
+ # has_many assoc, options.merge(conditions: ["#{model.effective_to_column_sql} = :date", :date=>END_OF_TIME)]
58
+ define_method assoc do
59
+ send(:"#{assoc}_iterations").current
60
+ end
61
+ # children at some date
62
+ define_method :"#{assoc}_at" do |date=nil|
63
+ # has_many assoc, options.merge(conditions: [%{#{model.effective_from_column_sql}<=:date AND #{model.effective_to_column_sql}>:date}, :date=>model.effective_date(date)]
64
+ send(:"#{assoc}_iterations").scoped.at(date) # scoped necessary here to avoid delegation to Array
65
+ end
66
+
67
+ # all children identities
68
+ define_method :"#{assoc}_identities" do
69
+ # send(:"#{assoc}_iterations").select("DISTINCT #{other_model.identity_column_sql}").order(other_model.identity_column_sql).pluck(:identity)
70
+ # other_model.unscoped.where(fk=>send(pk)).identities
71
+ send(:"#{assoc}_iterations").identities
72
+ end
73
+
74
+ # children identities at a date
75
+ define_method :"#{assoc}_identities_at" do |date=nil|
76
+ # send(:"#{assoc}_iterations_at", date).select("DISTINCT #{other_model.identity_column_sql}").order(other_model.identity_column_sql).pluck(:identity)
77
+ # other_model.unscoped.where(fk=>send(pk)).identities_at(date)
78
+ send(:"#{assoc}_iterations").identities_at(date)
79
+ end
80
+
81
+ # current children identities
82
+ define_method :"#{assoc}_current_identities" do
83
+ # send(assoc).select("DISTINCT #{other_model.identity_column_sql}").order(other_model.identity_column_sql).pluck(:identity)
84
+ # other_model.unscoped.where(fk=>send(pk)).current_identities
85
+ send(:"#{assoc}_iterations").current_identities
86
+ end
87
+
88
+ end
89
+
90
+ # Since this code has been extracted from a Rails 3 project, we need to adapt to Rails 4
91
+ # For a gradual transition and to allow compatibility with Rails 3 we'll provide this
92
+ # for the time being:
93
+ if ActiveRecord::VERSION::MAJOR > 3
94
+ def scoped
95
+ all
96
+ end
97
+ end
98
+
99
+ end
100
+
101
+ end
@@ -0,0 +1,142 @@
1
+ module ActsAsScd
2
+
3
+ # BlockUpdater is a utilty to batch-update a SCD table, in a way
4
+ # in which at a given date all the identities of the table are updated.
5
+ # No iteration will span the update date. Current iterations at the
6
+ # update date will end at that date and new iterations will start at it.
7
+ #
8
+ # This update style simplifies managing tables with identities;
9
+ # effective periods ara shared by all the identities.
10
+ #
11
+ class BlockUpdater
12
+
13
+ # Block-Update a table with identities:
14
+ #
15
+ # ActsAsScd::BlockUpdater.update Model, date do |updater|
16
+ # new_records.each do |record_attributes|
17
+ # updater.add(record_attributes) do |record|
18
+ # raise "Error" if record.errors.present?
19
+ # end
20
+ # end
21
+ # end
22
+ #
23
+ def self.update(model, fecha, options={})
24
+ updater = new(model, fecha, options={})
25
+ model.transaction do
26
+ updater.start
27
+ yield updater
28
+ updater.finish
29
+ end
30
+ updater
31
+ end
32
+
33
+ # Delete a block-update.
34
+ def self.delete(model, fecha, options={})
35
+ updater = new(model, fecha, options={})
36
+ updater.delete_all
37
+ end
38
+
39
+ # Check if a block-update has been performed at the given date
40
+ def self.exists?(model, fecha, options={})
41
+ updater = new(model, fecha, options={})
42
+ updater.exists?
43
+ end
44
+
45
+ def self.count(model, fecha, options={})
46
+ updater = new(model, fecha, options={})
47
+ updater.count
48
+ end
49
+
50
+ def initialize(model, fecha, options={})
51
+ @model = model
52
+ @fecha = fecha
53
+ @preterminate = false
54
+ @raise_on_error = options.delete :raise_on_error
55
+ @scope = options.delete :scope
56
+ # @extend_from = options[:extend_from]
57
+ # @unterminate = options[:unterminate]
58
+ @iteration_options = options.dup
59
+ end
60
+
61
+ attr_reader :model, :fecha, :counters, :new_items, :old_items, :missing_items
62
+
63
+ def scoped_model
64
+ if @scope.present?
65
+ if Symbol === @scope
66
+ @model.send(@scope)
67
+ else
68
+ @model.where(scope)
69
+ end
70
+ else
71
+ @model
72
+ end
73
+ end
74
+
75
+ def start
76
+ @new_items = 0
77
+ @old_items = 0
78
+ @missing_items = 0
79
+ if @preterminate
80
+ @pre_items = scoped_model.current.count
81
+ scoped_model.current.update_all(:effective_to=>model.effective_date(fecha))
82
+ # @unterminate = true
83
+ @iteration_options[:unterminate] = true
84
+ end
85
+ self
86
+ end
87
+
88
+ def add(identity, attributes={})
89
+ record = model.create_iteration(identity, attributes, fecha, @iteration_options)
90
+ yield record if block_given?
91
+ raise "Errors: #{record.errors.full_messages}" if @raise_on_error && record.errors.present?
92
+ if record.antecessor
93
+ @old_items += 1
94
+ else
95
+ @new_items += 1
96
+ end
97
+ record
98
+ end
99
+
100
+ def finish
101
+ if @preterminate
102
+ @missing_items = @pre_items - @old_items
103
+ else
104
+ scoped_model.current.where('effective_from < :fecha', fecha: model.effective_date(fecha)).each do |record|
105
+ record.terminate_identity fecha
106
+ @missing_items += 1
107
+ end
108
+ end
109
+ self
110
+ end
111
+
112
+ def delete_all
113
+ date = ActsAsScd::Period.date(fecha)
114
+ query = scoped_model.where(effective_from: date)
115
+ @missing_items = query.count
116
+ query.destroy_all
117
+ self
118
+ end
119
+
120
+ def identity_exists?(identity)
121
+ date = ActsAsScd::Period.date(fecha)
122
+ scoped_model.where(effective_from: date, identity: identity).exists?
123
+ end
124
+
125
+ def find_identity(identity)
126
+ date = ActsAsScd::Period.date(fecha)
127
+ scoped_model.where(effective_from: date, identity: identity).first
128
+ end
129
+
130
+ def exists?
131
+ date = ActsAsScd::Period.date(fecha)
132
+ scoped_model.where(effective_from: date).exists?
133
+ end
134
+
135
+ def count
136
+ date = ActsAsScd::Period.date(fecha)
137
+ scoped_model.where(effective_from: date).count
138
+ end
139
+
140
+ end
141
+
142
+ end
@@ -0,0 +1,240 @@
1
+ module ActsAsScd
2
+
3
+
4
+ module ClassMethods
5
+
6
+ # Return objects representing identities; (with a single attribute, :identity)
7
+ # Warning: do not chain this method after other queries;
8
+ # any query should be applied after this method.
9
+ # If identities are required for an association, either latest, earliest or initial can be used
10
+ # (which one is appropriate depends on desired result, data contents, etc.; initial/current are faster)
11
+ def distinct_identities
12
+ # Note that since Rails 2.3.13, when pluck(col) is applied to distinct_identities
13
+ # the "DISTINCT" is lost from the SELECT if added explicitly as in .select('DISTINCT #{col}'),
14
+ # so we have avoid explicit use of DISTINCT in distinct_identities.
15
+ # This can be used on association queries
16
+ if ActiveRecord::VERSION::MAJOR > 3
17
+ unscope(:select).reorder(identity_column_sql).select(identity_column_sql).uniq
18
+ else
19
+ query = scoped.with_default_scope
20
+ query.select_values.clear
21
+ query.reorder(identity_column_sql).select(identity_column_sql).uniq
22
+ end
23
+ end
24
+
25
+ def ordered_identities
26
+ distinct_identities.pluck(identity_column_sql)
27
+ end
28
+
29
+ # This can be applied to an ordered query (but returns an Array, not a query)
30
+ def identities
31
+ # pluck(identity_column_sql).uniq # does not work if select has been applied
32
+ scoped.map(&IDENTITY_COLUMN).uniq
33
+ end
34
+
35
+ def identities_at(date=nil)
36
+ at(date).identities
37
+ end
38
+
39
+ def current_identities
40
+ current.identities
41
+ end
42
+
43
+ def identity_column_sql(table_alias=nil)
44
+ %{"#{table_alias || table_name}"."#{IDENTITY_COLUMN}"}
45
+ end
46
+
47
+ def effective_from_column_sql(table_alias=nil)
48
+ %{"#{table_alias || table_name}"."#{START_COLUMN}"}
49
+ end
50
+
51
+ def effective_to_column_sql(table_alias=nil)
52
+ %{"#{table_alias || table_name}"."#{END_COLUMN}"}
53
+ end
54
+
55
+ def effective_date(d)
56
+ Period.date(d)
57
+ end
58
+
59
+ # Note that find_by_identity will return nil if there's not a current iteration of the identity
60
+ def find_by_identity(identity, at_date=nil)
61
+ # (at_date.nil? ? current : at(at_date)).where(IDENTITY_COLUMN=>identity).first
62
+ if at_date.nil?
63
+ q = current
64
+ else
65
+ q = at(at_date)
66
+ end
67
+ q = q.where(IDENTITY_COLUMN=>identity)
68
+ q.first
69
+ end
70
+
71
+ def identity_exists?(identity, at_date=nil)
72
+ (at_date.nil? ? self : at(at_date)).where(IDENTITY_COLUMN=>identity).exists?
73
+ end
74
+
75
+ # The first iteration can be defined with a specific start date, but
76
+ # that is in general a bad idea, since it complicates obtaining
77
+ # the first iteration
78
+ def create_identity(attributes, start=nil)
79
+ start ||= START_OF_TIME
80
+ create(attributes.merge(START_COLUMN=>start || START_OF_TIME))
81
+ end
82
+
83
+ # Create a new iteration
84
+ # options
85
+ # :unterminate - if the identity exists and is terminated, unterminate it (extending the last iteration to the new date)
86
+ # :extend_from - if no prior iteration exists, extend effective_from to the start-of-time
87
+ # (TODO: consider making :extend_from the default, adding an option for the opposite...)
88
+ def create_iteration(identity, attribute_changes, start=nil, options={})
89
+ start = effective_date(start || Date.today)
90
+ transaction do
91
+ current_record = find_by_identity(identity)
92
+ if !current_record && options[:unterminate]
93
+ current_record = latest_of(identity) # terminated.where(IDENTITY_COLUMN=>identity).first
94
+ # where(IDENTITY_COLUMN=>identity).where("#{effective_to_column_sql} < #{END_OF_TIME}").reorder("#{effective_to_column_sql} desc").limit(1).first
95
+ end
96
+ attributes = {IDENTITY_COLUMN=>identity}.with_indifferent_access
97
+ if current_record
98
+ non_replicated_attrs = %w[id effective_from effective_to updated_at created_at]
99
+ attributes = attributes.merge current_record.attributes.with_indifferent_access.except(*non_replicated_attrs)
100
+ end
101
+ start = START_OF_TIME if options[:extend_from] && !identity_exists?(identity)
102
+ attributes = attributes.merge(START_COLUMN=>start).merge(attribute_changes.with_indifferent_access.except(START_COLUMN, END_COLUMN))
103
+ new_record = create(attributes)
104
+ if new_record.errors.blank? && current_record
105
+ # current_record.update_attributes END_COLUMN=>start
106
+ current_record.send :"#{END_COLUMN}=", start
107
+ current_record.save validate: false
108
+ end
109
+ new_record
110
+ end
111
+ end
112
+
113
+ def terminate_identity(identity, finish=Date.today)
114
+ finish = effective_date(finish)
115
+ transaction do
116
+ current_record = find_by_identity(identity)
117
+ current_record.update_attributes END_COLUMN=>finish
118
+ end
119
+ end
120
+
121
+ # Association yo be used in a parent class which has identity and has children
122
+ # which have identities too;
123
+ # the association is implemented through the identity, not the PK.
124
+ # The inverse association should be belongs_to_identity
125
+ def has_many_iterations_through_identity(assoc, options={})
126
+ fk = options[:foreign_key] || :"#{model_name.to_s.underscore}_identity"
127
+ assoc_singular = assoc.to_s.singularize
128
+ other_model_name = options[:class_name] || assoc_singular.camelize
129
+ other_model = other_model_name.constantize
130
+ pk = IDENTITY_COLUMN
131
+
132
+ # all children iterations
133
+ has_many :"#{assoc_singular}_iterations", class_name: other_model_name, foreign_key: fk, primary_key: pk
134
+
135
+ # current_children
136
+ has_many assoc, ->{ where "#{other_model.effective_to_column_sql}=#{END_OF_TIME}" },
137
+ options.reverse_merge(foreign_key: fk, primary_key: pk)
138
+ # has_many assoc, {:foreign_key=>fk, :primary_key=>pk, :conditions=>"#{other_model.effective_to_column_sql}=#{END_OF_TIME}"}.merge(options)
139
+
140
+ # children at some date
141
+ define_method :"#{assoc}_at" do |date|
142
+ # other_model.unscoped.at(date).where(fk=>send(pk))
143
+ send(:"#{assoc_singular}_iterations").scoped.at(date)
144
+ end
145
+
146
+ # all children identities
147
+ define_method :"#{assoc_singular}_identities" do
148
+ # send(:"#{assoc}_iterations").select("DISTINCT #{other_model.identity_column_sql}").reorder(other_model.identity_column_sql).pluck(:identity)
149
+ # other_model.unscoped.where(fk=>send(pk)).identities
150
+ send(:"#{assoc_singular}_iterations").identities
151
+ end
152
+
153
+ # children identities at a date
154
+ define_method :"#{assoc_singular}_identities_at" do |date=nil|
155
+ # send(:"#{assoc}_iterations_at", date).select("DISTINCT #{other_model.identity_column_sql}").reorder(other_model.identity_column_sql).pluck(:identity)
156
+ # other_model.unscoped.where(fk=>send(pk)).identities_at(date)
157
+ send(:"#{assoc_singular}_iterations").identities_at(date)
158
+ end
159
+
160
+ # current children identities
161
+ define_method :"#{assoc_singular}_current_identities" do
162
+ # send(assoc).select("DISTINCT #{other_model.identity_column_sql}").reorder(other_model.identity_column_sql).pluck(:identity)
163
+ # other_mode.unscoped.where(fk=>send(pk)).current_identities
164
+ send(:"#{assoc_singular}_iterations").current_identities
165
+ end
166
+
167
+ end
168
+
169
+ # Association to be used in a parent class which has identity and has children
170
+ # which don't have identities;
171
+ # the association is implemented through the identity, not the PK.
172
+ # The inverse association should be belongs_to_identity
173
+ def has_many_through_identity(assoc, options={})
174
+ fk = :"#{model_name.to_s.underscore}_identity"
175
+ pk = IDENTITY_COLUMN
176
+
177
+ has_many assoc, {:foreign_key=>fk, :primary_key=>pk}.merge(options)
178
+ end
179
+
180
+ def identity_column_definition
181
+ @slowly_changing_columns.first
182
+ end
183
+
184
+ def slow_changing_migration
185
+ migration = ""
186
+
187
+ migration << "def up\n"
188
+ @slowly_changing_columns.each do |col, args|
189
+ migration << " add_column :#{table_name}, :#{col}, #{args.inspect.unwrap('[]')}\n"
190
+ end
191
+ @slowly_changing_indices.each do |index|
192
+ migration << " add_index :#{table_name}, #{index.inspect}\n"
193
+ end
194
+ migration << "end\n"
195
+
196
+ migration << "def down\n"
197
+ @slowly_changing_columns.each do |col, args|
198
+ migration << " remove_column :#{table_name}, :#{col}\n"
199
+ end
200
+ migration << "end\n"
201
+
202
+ end
203
+
204
+ def effective_periods(*args)
205
+ # periods = unscoped.select("DISTINCT effective_from, effective_to").order('effective_from, effective_to')
206
+ if ActiveRecord::VERSION::MAJOR > 3
207
+ # periods = unscope(where: [:effective_from, :effective_to]).select("DISTINCT effective_from, effective_to").reorder('effective_from, effective_to')
208
+ periods = unscope(where: [:effective_from, :effective_to]).select([:effective_from, :effective_to]).uniq.reorder('effective_from, effective_to')
209
+ else
210
+ query = scoped.with_default_scope
211
+ query.select_values.clear
212
+ periods = query.reorder('effective_from, effective_to').select([:effective_from, :effective_to]).uniq
213
+ end
214
+
215
+ # formerly unscoped was used, so any desired condition had to be defined here
216
+ periods = periods.where(*args) if args.present?
217
+
218
+ periods.map{|p| Period[p.effective_from, p.effective_to]}
219
+ end
220
+
221
+ # def effective_spans
222
+ # # select all distinct effective_from, and effective_to, order, return in pairs
223
+ # end
224
+
225
+ # Most recent iteration (terminated or not)
226
+ def latest_of(identity)
227
+ where(identity:identity).reorder('effective_to desc').limit(1).first
228
+ end
229
+
230
+ def earliest_of(identity)
231
+ where(identity:identity).reorder('effective_to asc').limit(1).first
232
+ end
233
+
234
+ def all_of(identity)
235
+ where(identity:identity).reorder('effective_from asc')
236
+ end
237
+
238
+ end
239
+
240
+ end