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