acts_as_scd 0.0.1

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