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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.rdoc +43 -0
- data/Rakefile +32 -0
- data/lib/acts_as_scd/base_class_methods.rb +101 -0
- data/lib/acts_as_scd/block_updater.rb +142 -0
- data/lib/acts_as_scd/class_methods.rb +240 -0
- data/lib/acts_as_scd/initialize.rb +114 -0
- data/lib/acts_as_scd/instance_methods.rb +105 -0
- data/lib/acts_as_scd/period.rb +135 -0
- data/lib/acts_as_scd/version.rb +3 -0
- data/lib/acts_as_scd.rb +31 -0
- data/lib/tasks/acts_as_scd_tasks.rake +4 -0
- data/test/acts_as_scd_test.rb +680 -0
- data/test/dummy/README.rdoc +28 -0
- data/test/dummy/Rakefile +6 -0
- data/test/dummy/app/assets/javascripts/application.js +13 -0
- data/test/dummy/app/assets/stylesheets/application.css +15 -0
- data/test/dummy/app/controllers/application_controller.rb +5 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/models/association.rb +9 -0
- data/test/dummy/app/models/city.rb +19 -0
- data/test/dummy/app/models/commercial_delegate.rb +9 -0
- data/test/dummy/app/models/country.rb +29 -0
- data/test/dummy/app/views/layouts/application.html.erb +14 -0
- data/test/dummy/bin/bundle +3 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/config/application.rb +23 -0
- data/test/dummy/config/boot.rb +5 -0
- data/test/dummy/config/database.yml +25 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +37 -0
- data/test/dummy/config/environments/production.rb +83 -0
- data/test/dummy/config/environments/test.rb +41 -0
- data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/test/dummy/config/initializers/cookies_serializer.rb +3 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
- data/test/dummy/config/initializers/inflections.rb +16 -0
- data/test/dummy/config/initializers/mime_types.rb +4 -0
- data/test/dummy/config/initializers/session_store.rb +3 -0
- data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/test/dummy/config/locales/en.yml +23 -0
- data/test/dummy/config/locales/scd.en.yml +7 -0
- data/test/dummy/config/routes.rb +56 -0
- data/test/dummy/config/secrets.yml +22 -0
- data/test/dummy/config.ru +4 -0
- data/test/dummy/db/test.sqlite3 +0 -0
- data/test/dummy/log/test.log +24444 -0
- data/test/dummy/public/404.html +67 -0
- data/test/dummy/public/422.html +67 -0
- data/test/dummy/public/500.html +66 -0
- data/test/dummy/public/favicon.ico +0 -0
- data/test/fixtures/cities.yml +95 -0
- data/test/fixtures/countries.yml +83 -0
- data/test/test_helper.rb +15 -0
- 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
|