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
@@ -0,0 +1,114 @@
|
|
1
|
+
module ActsAsScd
|
2
|
+
|
3
|
+
# Internal value to represent the start of time
|
4
|
+
START_OF_TIME = 0
|
5
|
+
# Internal value to represent the end of time
|
6
|
+
END_OF_TIME = 99999999
|
7
|
+
|
8
|
+
# TODO: paremeterize the column names
|
9
|
+
|
10
|
+
# Column that represents the identity of an entity
|
11
|
+
IDENTITY_COLUMN = :identity
|
12
|
+
# Column that represents start of an iteration's life
|
13
|
+
START_COLUMN = :effective_from
|
14
|
+
# Column that represents end of an iteration's life
|
15
|
+
END_COLUMN = :effective_to
|
16
|
+
|
17
|
+
def self.initialize_scd(model)
|
18
|
+
model.extend ClassMethods
|
19
|
+
|
20
|
+
# Current iterations
|
21
|
+
model.scope :current, ->{model.where("#{model.effective_to_column_sql} = :date", :date=>END_OF_TIME)}
|
22
|
+
model.scope :initial, ->{model.where("#{model.effective_from_column_sql} = :date", :date=>START_OF_TIME)}
|
23
|
+
# Iterations effective at given date
|
24
|
+
# Note that since Array has an 'at' method, this cannot be applied directly to
|
25
|
+
# associations (the Array method would be used after generating an Array from the query).
|
26
|
+
# It is necessary to use .scoped.at(...) for associations.
|
27
|
+
model.scope :at, ->(date=nil){
|
28
|
+
# TODO: consider renaming this to current_at or active_at to avoid having to use
|
29
|
+
# scoped with associations
|
30
|
+
if date.present?
|
31
|
+
model.where(%{#{model.effective_from_column_sql}<=:date AND #{model.effective_to_column_sql}>:date}, :date=>model.effective_date(date))
|
32
|
+
else
|
33
|
+
model.current
|
34
|
+
end
|
35
|
+
}
|
36
|
+
# Iterations superseded/terminated
|
37
|
+
model.scope :ended, ->{model.where("#{model.effective_to_column_sql} < :date", :date=>END_OF_TIME)}
|
38
|
+
model.scope :earliest, ->(identity=nil){
|
39
|
+
if identity
|
40
|
+
identity_column = model.identity_column_sql('earliest_tmp')
|
41
|
+
if Array==identity
|
42
|
+
identity_list = identity.map{|i| model.connection.quote(i)}*','
|
43
|
+
where_condition = "WHERE #{identity_column} IN (#{identity_list})"
|
44
|
+
else
|
45
|
+
where_condition = "WHERE #{identity_column}=#{model.connection.quote(identity)}"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
model.where(
|
49
|
+
%{(#{model.identity_column_sql}, #{model.effective_from_column_sql}) IN
|
50
|
+
(SELECT #{model.identity_column_sql('earliest_tmp')},
|
51
|
+
MIN(#{model.effective_from_column_sql('earliest_tmp')}) AS earliest_from
|
52
|
+
FROM #{model.table_name} AS "earliest_tmp"
|
53
|
+
#{where_condition}
|
54
|
+
GROUP BY #{model.identity_column_sql('earliest_tmp')})
|
55
|
+
}
|
56
|
+
)
|
57
|
+
}
|
58
|
+
# Latest iteration (terminated or current) of each identity
|
59
|
+
model.scope :latest, ->(identity=nil){
|
60
|
+
if identity
|
61
|
+
identity_column = model.identity_column_sql('latest_tmp')
|
62
|
+
if Array===identity
|
63
|
+
identity_list = identity.map{|i| model.connection.quote(i)}*','
|
64
|
+
where_condition = "WHERE #{identity_column} IN (#{identity_list})"
|
65
|
+
else
|
66
|
+
where_condition = "WHERE #{identity_column}=#{model.connection.quote(identity)}"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
model.where(
|
70
|
+
%{(#{model.identity_column_sql}, #{model.effective_to_column_sql}) IN
|
71
|
+
(SELECT #{model.identity_column_sql('latest_tmp')},
|
72
|
+
MAX(#{model.effective_to_column_sql('latest_tmp')}) AS latest_to
|
73
|
+
FROM #{model.table_name} AS "latest_tmp"
|
74
|
+
#{where_condition}
|
75
|
+
GROUP BY #{model.identity_column_sql('latest_tmp')})
|
76
|
+
}
|
77
|
+
)
|
78
|
+
}
|
79
|
+
# Last superseded/terminated iterations
|
80
|
+
# model.scope :last_ended, ->{model.where(%{#{model.effective_to_column_sql} = (SELECT max(#{model.effective_to_column_sql('max_to_tmp')}) FROM "#{model.table_name}" AS "max_to_tmp" WHERE #{model.effective_to_column_sql('max_to_tmp')}<#{END_OF_TIME})})}
|
81
|
+
# last iterations of terminated identities
|
82
|
+
# model.scope :terminated, ->{model.where(%{#{model.effective_to_column_sql}<#{END_OF_TIME} AND #{model.effective_to_column_sql}=(SELECT max(#{model.effective_to_column_sql('max_to_tmp')}) FROM "#{model.table_name}" AS "max_to_tmp")})}
|
83
|
+
model.scope :terminated, ->(identity=nil){
|
84
|
+
where_condition = identity && " WHERE #{model.identity_column_sql('max_to_tmp')}=#{model.connection.quote(identity)} "
|
85
|
+
model.where(
|
86
|
+
%{#{model.effective_to_column_sql}<#{END_OF_TIME}
|
87
|
+
AND (#{model.identity_column_sql}, #{model.effective_to_column_sql}) IN
|
88
|
+
(SELECT #{model.identity_column_sql('max_to_tmp')},
|
89
|
+
max(#{model.effective_to_column_sql('max_to_tmp')})
|
90
|
+
FROM "#{model.table_name}" AS "max_to_tmp" #{where_condition})
|
91
|
+
}
|
92
|
+
)
|
93
|
+
}
|
94
|
+
# iterations superseded
|
95
|
+
model.scope :superseded, ->(identity=nil){
|
96
|
+
where_condition = identity && " AND #{model.identity_column_sql('max_to_tmp')}=#{model.connection.quote(identity)} "
|
97
|
+
model.where(
|
98
|
+
%{(#{model.identity_column_sql}, #{model.effective_to_column_sql}) IN
|
99
|
+
(SELECT #{model.identity_column_sql('max_to_tmp')},
|
100
|
+
max(#{model.effective_to_column_sql('max_to_tmp')})
|
101
|
+
FROM "#{model.table_name}" AS "max_to_tmp"
|
102
|
+
WHERE #{model.effective_to_column_sql('max_to_tmp')}<#{END_OF_TIME})
|
103
|
+
#{where_condition}
|
104
|
+
AND EXISTS (SELECT * FROM "#{model.table_name}" AS "ex_from_tmp"
|
105
|
+
WHERE #{model.effective_from_column_sql('ex_from_tmp')}==#{model.effective_to_column_sql})
|
106
|
+
}
|
107
|
+
)
|
108
|
+
}
|
109
|
+
model.before_validation :compute_identity
|
110
|
+
model.validates_uniqueness_of IDENTITY_COLUMN, :scope=>[START_COLUMN, END_COLUMN], :message=>"El periodo de vigencia no es válido"
|
111
|
+
model.before_destroy :remove_this_iteration
|
112
|
+
end
|
113
|
+
|
114
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
module ActsAsScd
|
2
|
+
|
3
|
+
# TODO: replace identity by send(IDENTITY_COLUMN)...
|
4
|
+
|
5
|
+
def current
|
6
|
+
self.class.find_by_identity(identity)
|
7
|
+
end
|
8
|
+
|
9
|
+
def initial
|
10
|
+
self.class.initial.where(IDENTITY_COLUMN=>identity).first
|
11
|
+
end
|
12
|
+
|
13
|
+
def at(date=nil)
|
14
|
+
if date.present?
|
15
|
+
self.class.find_by_identity(identity, date)
|
16
|
+
else
|
17
|
+
current
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def successor
|
22
|
+
return nil if effective_to==END_OF_TIME
|
23
|
+
self.class.where(identity:identity, effective_from:effective_to).first
|
24
|
+
end
|
25
|
+
|
26
|
+
def antecessor
|
27
|
+
return nil if effective_from==START_OF_TIME
|
28
|
+
self.class.where(identity:identity, effective_to:effective_from).first
|
29
|
+
end
|
30
|
+
|
31
|
+
def successors
|
32
|
+
return self.class.where('1=0') if effective_to==END_OF_TIME
|
33
|
+
self.class.where(identity:identity).where('effective_from>=:date', date: effective_to).reorder('effective_from')
|
34
|
+
end
|
35
|
+
|
36
|
+
def antecessors
|
37
|
+
return self.class.where('1=0') if effective_from==START_OF_TIME
|
38
|
+
self.class.where(identity:identity).where('effective_to<=:date', date: effective_from).reorder('effective_to')
|
39
|
+
end
|
40
|
+
|
41
|
+
def history
|
42
|
+
self.class.all_of(identity)
|
43
|
+
end
|
44
|
+
|
45
|
+
def latest
|
46
|
+
self.class.where(identity:identity).reorder('effective_to desc').limit(1).first
|
47
|
+
end
|
48
|
+
|
49
|
+
def earliest
|
50
|
+
self.class.where(identity:identity).reorder('effective_from asc').limit(1).first
|
51
|
+
end
|
52
|
+
|
53
|
+
def terminate_identity(finish=Date.today)
|
54
|
+
finish = self.class.effective_date(finish)
|
55
|
+
update_attributes END_COLUMN=>finish
|
56
|
+
end
|
57
|
+
|
58
|
+
def ended?
|
59
|
+
effective_to < END_OF_TIME
|
60
|
+
end
|
61
|
+
|
62
|
+
def ended_at?(date)
|
63
|
+
effective_to <= self.class.effective_date(date)
|
64
|
+
end
|
65
|
+
|
66
|
+
def effective_period
|
67
|
+
Period[effective_from, effective_to]
|
68
|
+
end
|
69
|
+
|
70
|
+
def effective_from_date
|
71
|
+
Period::DateValue[effective_from].to_date
|
72
|
+
end
|
73
|
+
|
74
|
+
def effective_to_date
|
75
|
+
Period::DateValue[effective_to].to_date
|
76
|
+
end
|
77
|
+
|
78
|
+
def initial?
|
79
|
+
effective_period.initial?
|
80
|
+
end
|
81
|
+
|
82
|
+
def current?
|
83
|
+
effective_period.current?
|
84
|
+
end
|
85
|
+
|
86
|
+
def past_limited?
|
87
|
+
effective_period.past_limited?
|
88
|
+
end
|
89
|
+
|
90
|
+
def future_limited?
|
91
|
+
effective_period.future_limited?
|
92
|
+
end
|
93
|
+
|
94
|
+
def limited?
|
95
|
+
effective_period.limited?
|
96
|
+
end
|
97
|
+
|
98
|
+
def remove_this_iteration
|
99
|
+
s = successor
|
100
|
+
s.update_attributes effective_from: self.effective_from if s
|
101
|
+
a = antecessor
|
102
|
+
a.update_attributes effective_to: self.effective_to if a
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
module ActsAsScd
|
2
|
+
|
3
|
+
class Period
|
4
|
+
|
5
|
+
class DateValue
|
6
|
+
def initialize(d)
|
7
|
+
|
8
|
+
d = d.strftime('%Y%m%d') if d.respond_to?(:strftime)
|
9
|
+
if String===d && d =~ /\A(\d\d\d\d)-(\d\d)-(\d\d)\Z/
|
10
|
+
d = $1.to_i*10000 + $2.to_i*100 + $3.to_i
|
11
|
+
end
|
12
|
+
@value = d && d.to_i
|
13
|
+
end
|
14
|
+
|
15
|
+
attr_reader :value
|
16
|
+
|
17
|
+
def to_date
|
18
|
+
begin
|
19
|
+
Date.new *parse
|
20
|
+
rescue
|
21
|
+
raise parse.inspect
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def parse
|
26
|
+
y = @value/10000
|
27
|
+
v = @value%10000
|
28
|
+
m = v/100
|
29
|
+
d = v%100
|
30
|
+
[y,m,d]
|
31
|
+
end
|
32
|
+
|
33
|
+
def to_s
|
34
|
+
if @value==START_OF_TIME
|
35
|
+
''
|
36
|
+
elsif @value==END_OF_TIME
|
37
|
+
''
|
38
|
+
else
|
39
|
+
y,m,d = parse
|
40
|
+
I18n.l Date.new(y, m, d)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
include ModalSupport::BracketConstructor
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.date(date)
|
48
|
+
DateValue[date].value
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.date_to_s(date)
|
52
|
+
DateValue[date].to_s
|
53
|
+
end
|
54
|
+
|
55
|
+
attr_reader :start, :end
|
56
|
+
def from
|
57
|
+
@start
|
58
|
+
end
|
59
|
+
def to
|
60
|
+
@end
|
61
|
+
end
|
62
|
+
|
63
|
+
def initialize(from, to)
|
64
|
+
@start = Period.date(from || START_OF_TIME)
|
65
|
+
@end = Period.date(to || END_OF_TIME)
|
66
|
+
end
|
67
|
+
|
68
|
+
include ModalSupport::StateEquivalent
|
69
|
+
|
70
|
+
def includes?(date)
|
71
|
+
date = Period.date(date)
|
72
|
+
@start <= date && date < @end
|
73
|
+
end
|
74
|
+
|
75
|
+
include ModalSupport::BracketConstructor
|
76
|
+
|
77
|
+
def to_s(options={})
|
78
|
+
if @start<=START_OF_TIME
|
79
|
+
if @end>=END_OF_TIME
|
80
|
+
options[:always] || I18n.t(:"scd.period.always") || '-'
|
81
|
+
else
|
82
|
+
"#{options[:until] || I18n.t(:"scd.period.until") || 'to'} #{Period.date_to_s(@end)}"
|
83
|
+
end
|
84
|
+
else
|
85
|
+
if @end>=END_OF_TIME
|
86
|
+
"#{options[:since] || I18n.t(:"scd.period.from") || 'since'} #{Period.date_to_s(@start)}"
|
87
|
+
else
|
88
|
+
[Period.date_to_s(@start), options[:between] || I18n.t(:"scd.period.between") || '-', Period.date_to_s(@end)].compact*' '
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def valid?
|
94
|
+
@start < @end
|
95
|
+
end
|
96
|
+
|
97
|
+
def empty?
|
98
|
+
@start >= @end
|
99
|
+
end
|
100
|
+
|
101
|
+
def past_limited?
|
102
|
+
@start > START_OF_TIME
|
103
|
+
end
|
104
|
+
|
105
|
+
def future_limited?
|
106
|
+
@end < END_OF_TIME
|
107
|
+
end
|
108
|
+
|
109
|
+
def limited?
|
110
|
+
past_limited? || future_limited?
|
111
|
+
end
|
112
|
+
|
113
|
+
def initial?
|
114
|
+
@start == START_OF_TIME
|
115
|
+
end
|
116
|
+
|
117
|
+
def current?
|
118
|
+
@end == END_OF_TIME
|
119
|
+
end
|
120
|
+
|
121
|
+
def reference_date
|
122
|
+
if @start <= START_OF_TIME
|
123
|
+
if @end >= END_OF_TIME
|
124
|
+
DateValue[Date.today].value
|
125
|
+
else
|
126
|
+
DateValue[DateValue[@end].to_date - 1].value
|
127
|
+
end
|
128
|
+
else
|
129
|
+
@start
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
end
|
134
|
+
|
135
|
+
end
|
data/lib/acts_as_scd.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'modalsupport'
|
2
|
+
require 'acts_as_scd/initialize'
|
3
|
+
require 'acts_as_scd/period'
|
4
|
+
require 'acts_as_scd/instance_methods'
|
5
|
+
require 'acts_as_scd/class_methods'
|
6
|
+
require 'acts_as_scd/base_class_methods'
|
7
|
+
require 'acts_as_scd/block_updater'
|
8
|
+
|
9
|
+
module ActsAsScd
|
10
|
+
|
11
|
+
begin
|
12
|
+
require 'rails'
|
13
|
+
|
14
|
+
class Railtie < Rails::Railtie
|
15
|
+
initializer 'acts_as_scd.insert_into_active_record' do
|
16
|
+
ActiveSupport.on_load :active_record do
|
17
|
+
# ActiveRecord::Base.send(:include, ActsAsScd)
|
18
|
+
ActiveRecord::Base.extend ActsAsScd::BaseClassMethods
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
rescue LoadError
|
23
|
+
# ActiveRecord::Base.send(:include, ActAsScd) if defined?(ActiveRecord)
|
24
|
+
ActiveRecord::Base.extend ActsAsScd::BaseClassMethods if defined?(ActiveRecord)
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.included(model)
|
28
|
+
initialize_scd model
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|