temporality 0.0.4
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/LICENSE +20 -0
- data/README.md +39 -0
- data/lib/temporality/associations.rb +29 -0
- data/lib/temporality/attribute_overrides.rb +42 -0
- data/lib/temporality/auto_close.rb +11 -0
- data/lib/temporality/completeness.rb +21 -0
- data/lib/temporality/day_count.rb +10 -0
- data/lib/temporality/default_boundary_values.rb +15 -0
- data/lib/temporality/inclusion.rb +14 -0
- data/lib/temporality/overlap.rb +22 -0
- data/lib/temporality/schema.rb +33 -0
- data/lib/temporality/scopes.rb +10 -0
- data/lib/temporality/slice.rb +27 -0
- data/lib/temporality/slice_collection.rb +82 -0
- data/lib/temporality/time_span.rb +127 -0
- data/lib/temporality/time_span_collection.rb +61 -0
- data/lib/temporality/validation.rb +54 -0
- data/lib/temporality/validation_strategy.rb +23 -0
- data/lib/temporality/version.rb +7 -0
- data/lib/temporality/violation.rb +5 -0
- data/lib/temporality.rb +36 -0
- metadata +192 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 8fd5d7006b68f06a13dd0f46790440a31d179384
|
4
|
+
data.tar.gz: f4e6b4354ede8fc9c0ad6fa8717fe0a6b080f290
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d9dec839f133d29763adab32b68c017ecc12ef960398c8156c84f40e8bb4640c7161dcfa8d0e0f5268ff8e6d82ce7ed2b31e33bab330c3cbf979ada953a224ac
|
7
|
+
data.tar.gz: ecd202980a60ec146c429a45750dfffeff4337bb6aeac56c94f8a43aa4907f6fc058a59315587b850fcba471c670b4b253f3eedf72e557cd2052bc862b0a20fe
|
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2016 David FRANCOIS
|
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.md
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
Temporality [](http://travis-ci.org/davout/temporality) [](https://coveralls.io/r/davout/temporality?branch=master) [](http://badge.fury.io/rb/temporality)
|
2
|
+
=
|
3
|
+
|
4
|
+
## What is Temporality
|
5
|
+
Temporality adds the ability to `ActiveRecord::Base` descendants to validate temporal data on themselves, and their associations.
|
6
|
+
|
7
|
+
## Target features
|
8
|
+
- Completeness implique prevent_overlap! implique également d'être deferred jusqu'au commit de la transaction
|
9
|
+
- Enforce this is only used on belong_to associations
|
10
|
+
- Doit permettre de valider que le parent est rempli
|
11
|
+
- Doit permettre au parent de valider que les enfants sont inclus
|
12
|
+
- Doit ne pas permettre l'overlap des records enfants
|
13
|
+
|
14
|
+
- Doit faire l'auto-close des records précédents, avec callbacks
|
15
|
+
- Doit faire l'auto-close des enfants, avec callbacks
|
16
|
+
- Doit permettre d'initialiser l'enfant avec les bornes du parent en faisant par exemple parent.children.build
|
17
|
+
- Scopes AR : intersecting, contained, englobant
|
18
|
+
- Quand les bornes du parent sont changées on doit jouer les validations de l'enfant, sinon dans les autres cas, c'est l'enfant qu'on valide
|
19
|
+
- Que se passe-t-il quand on modifie les bornes du parent, mais qu'on modifie en même temps les bornes de l'enfant ?
|
20
|
+
|
21
|
+
## Examples
|
22
|
+
|
23
|
+
````ruby
|
24
|
+
Temporality.configure do |config|
|
25
|
+
config.error_strategy = :exception
|
26
|
+
end
|
27
|
+
|
28
|
+
Temporality.configure do |config|
|
29
|
+
config.error_strategy = :active_model
|
30
|
+
end
|
31
|
+
|
32
|
+
class Contract < ActiveRecord::Base
|
33
|
+
has_many :compensations
|
34
|
+
end
|
35
|
+
|
36
|
+
class Compensation < ActiveRecord::Base
|
37
|
+
belongs_to :contract, temporality: { inclusion: true, auto_close_previous: true, allow_overlap: false, completeness: true }
|
38
|
+
end
|
39
|
+
````
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Temporality
|
2
|
+
module Associations
|
3
|
+
|
4
|
+
# TODO : Use class-inheritable instance variables
|
5
|
+
|
6
|
+
DEFAULTS = { inclusion: true, completeness: false, prevent_overlap: false, auto_close: false }
|
7
|
+
|
8
|
+
def belongs_to(*args, &block)
|
9
|
+
@temporality ||= {}
|
10
|
+
assoc_name = args.first
|
11
|
+
|
12
|
+
if args.last.is_a?(Hash)
|
13
|
+
if opts = args.last.delete(:temporality)
|
14
|
+
opts.keys.each do |key|
|
15
|
+
unless DEFAULTS.keys.include?(key)
|
16
|
+
raise "Unknown option '#{key}', valid options are #{DEFAULTS.keys.map(&:to_s).join(', ')}"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
@temporality[assoc_name] = DEFAULTS.merge(opts)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
super(*args, &block)
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Temporality
|
2
|
+
|
3
|
+
module AttributeOverrides
|
4
|
+
|
5
|
+
def starts_on
|
6
|
+
floor_to_temporal_infinity(super)
|
7
|
+
end
|
8
|
+
|
9
|
+
def ends_on
|
10
|
+
ceil_to_temporal_infinity(super)
|
11
|
+
end
|
12
|
+
|
13
|
+
def starts_on=(d)
|
14
|
+
super(floor_to_temporal_infinity(d))
|
15
|
+
end
|
16
|
+
|
17
|
+
def ends_on=(d)
|
18
|
+
super(ceil_to_temporal_infinity(d))
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
#
|
24
|
+
# Floors the parameter to `Temporality::PAST_INFINITY`
|
25
|
+
#
|
26
|
+
def floor_to_temporal_infinity(d)
|
27
|
+
[d, Temporality::PAST_INFINITY].compact.max
|
28
|
+
end
|
29
|
+
|
30
|
+
#
|
31
|
+
# Ceils the parameter to `Temporality::FUTURE_INFINITY`
|
32
|
+
#
|
33
|
+
def ceil_to_temporal_infinity(d)
|
34
|
+
[d, Temporality::FUTURE_INFINITY].compact.min
|
35
|
+
end
|
36
|
+
|
37
|
+
|
38
|
+
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'temporality/validation_strategy'
|
2
|
+
|
3
|
+
module Temporality
|
4
|
+
class Completeness < ValidationStrategy
|
5
|
+
|
6
|
+
def validate
|
7
|
+
days = @model.day_count + (inverse.where('id <> ?', @model.id || -1).map(&:day_count).inject(&:+) || 0)
|
8
|
+
parent_days = @model.send(@assoc).day_count
|
9
|
+
|
10
|
+
raise Temporality::Violation.new(error_message) unless (parent_days == days)
|
11
|
+
end
|
12
|
+
|
13
|
+
def error_message
|
14
|
+
"#{@model.send(@assoc).class} record must have a temporally complete children collection for assocation #{inverse_name}"
|
15
|
+
end
|
16
|
+
|
17
|
+
# TODO : Check if in transaction ActiveRecord::Base.connection.open_transactions
|
18
|
+
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Temporality
|
2
|
+
module DefaultBoundaryValues
|
3
|
+
|
4
|
+
def self.included(base)
|
5
|
+
base.after_initialize :set_time_boundaries_defaults
|
6
|
+
|
7
|
+
def set_time_boundaries_defaults
|
8
|
+
self.starts_on = starts_on
|
9
|
+
self.ends_on = ends_on
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module Temporality
|
2
|
+
class Inclusion < ValidationStrategy
|
3
|
+
|
4
|
+
def validate
|
5
|
+
parent = @model.send(@assoc)
|
6
|
+
|
7
|
+
if parent && (parent.starts_on > @model.starts_on || parent.ends_on < @model.ends_on)
|
8
|
+
raise Temporality::Violation.new("Record of class #{self.class} is not temporally included in parent of class #{parent.class}, [#{@model.starts_on} - #{@model.ends_on}] is not included in [#{parent.starts_on} - #{parent.ends_on}]")
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'temporality/validation_strategy'
|
2
|
+
|
3
|
+
module Temporality
|
4
|
+
class Overlap < ValidationStrategy
|
5
|
+
|
6
|
+
def validate
|
7
|
+
overlapping = inverse.intersecting(@model)
|
8
|
+
|
9
|
+
if @model.id
|
10
|
+
overlapping = overlapping.where('id <> ?', @model.id)
|
11
|
+
end
|
12
|
+
|
13
|
+
raise Temporality::Violation.new(error_message) if overlapping.exists?
|
14
|
+
end
|
15
|
+
|
16
|
+
def error_message
|
17
|
+
"Found overlapping records for range [#{@model.starts_on} - #{@model.ends_on}]"
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
@@ -0,0 +1,33 @@
|
|
1
|
+
#
|
2
|
+
# Defines a `temporality` migration helper for use in a `create_table` block as well
|
3
|
+
# as a `temporality(:table)` helper to be used to alter existing table definitions.
|
4
|
+
#
|
5
|
+
module Temporality
|
6
|
+
module Schema
|
7
|
+
|
8
|
+
def self.include_helpers!
|
9
|
+
if Object.const_defined?(:ActiveRecord)
|
10
|
+
ActiveRecord::Migration.send(:include, MigrationHelper)
|
11
|
+
ActiveRecord::ConnectionAdapters::TableDefinition.send(:include, TableDefinitionHelper)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
module MigrationHelper
|
16
|
+
def temporality(table)
|
17
|
+
add_column(table, :starts_on, :date, null: false)
|
18
|
+
add_column(table, :ends_on, :date, null: false)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
module TableDefinitionHelper
|
23
|
+
def temporality
|
24
|
+
date(:starts_on, null: false)
|
25
|
+
date(:ends_on, null: false)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
Temporality::Schema.include_helpers!
|
33
|
+
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'temporality/time_span'
|
2
|
+
|
3
|
+
module Temporality
|
4
|
+
class Slice < TimeSpan
|
5
|
+
attr_accessor :value
|
6
|
+
|
7
|
+
def initialize(starts_on, ends_on, value)
|
8
|
+
@value = value
|
9
|
+
super(starts_on, ends_on)
|
10
|
+
end
|
11
|
+
|
12
|
+
def to_s
|
13
|
+
"(SL : #{super} -> #{@value})"
|
14
|
+
end
|
15
|
+
|
16
|
+
def intersect(time_span)
|
17
|
+
if time_span.is_a? TimeSpan
|
18
|
+
intersects?(time_span) ? Slice.new([@starts_on, time_span.starts_on].max, [@ends_on, time_span.ends_on].min, @value) : nil
|
19
|
+
elsif time_span.is_a? SliceCollection
|
20
|
+
r = time_span.map { |s| intersect s }.compact
|
21
|
+
r.size > 1 ? SliceCollection.new(s) : s
|
22
|
+
else
|
23
|
+
raise TypeError, 'Argument should be a TimeSpan or a SliceCollection'
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require 'temporality/time_span_collection'
|
2
|
+
require 'temporality/slice'
|
3
|
+
|
4
|
+
module Temporality
|
5
|
+
class SliceCollection < TimeSpanCollection
|
6
|
+
attr_accessor :slices
|
7
|
+
|
8
|
+
def initialize(values = nil)
|
9
|
+
collection_from_history(values) if values
|
10
|
+
end
|
11
|
+
|
12
|
+
def to_s
|
13
|
+
"{SLC : #{map(&:to_s) .join(', ')}}"
|
14
|
+
end
|
15
|
+
|
16
|
+
# TODO : Méthodes nécessaires ?
|
17
|
+
# def starts_on
|
18
|
+
# first.starts_on unless empty?
|
19
|
+
# end
|
20
|
+
|
21
|
+
# def ends_on
|
22
|
+
# last.ends_on unless empty?
|
23
|
+
# end
|
24
|
+
|
25
|
+
# Remplit les 'trous' d'une collection de slices par rapport à un time_span de référence
|
26
|
+
# en utilisant les valeurs disponibles dans la collection de slices passée en paramètre
|
27
|
+
def fill_missing_with!(slices, time_span)
|
28
|
+
self << slices.intersect(time_span.substract(self))
|
29
|
+
flatten!
|
30
|
+
sort!
|
31
|
+
end
|
32
|
+
|
33
|
+
def value_for(time_span)
|
34
|
+
detect do |slice|
|
35
|
+
slice.covers? time_span
|
36
|
+
end.value
|
37
|
+
end
|
38
|
+
|
39
|
+
def intersect(tsc)
|
40
|
+
if tsc.is_a? TimeSpanCollection
|
41
|
+
tsc.inject(SliceCollection.new) do |r, s|
|
42
|
+
r << intersect(s)
|
43
|
+
end.compact.sort
|
44
|
+
elsif tsc.is_a? TimeSpan
|
45
|
+
map { |s| s.intersect tsc }.compact
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def collection_from_history(values)
|
52
|
+
sorted_values = values.sort do |a, b|
|
53
|
+
a.starts_on <=> b.starts_on
|
54
|
+
end
|
55
|
+
|
56
|
+
previous_slice = nil
|
57
|
+
|
58
|
+
sorted_values.each_with_index do |v, _i|
|
59
|
+
slice = Slice.new(v.starts_on, v.ends_on, v.value)
|
60
|
+
|
61
|
+
if previous_slice
|
62
|
+
raise 'Overlapping slices!' if slice.starts_on <= previous_slice.ends_on
|
63
|
+
not_period_complete! if previous_slice.ends_on.advance(days: 1) < slice.starts_on
|
64
|
+
end
|
65
|
+
|
66
|
+
self.<<(slice)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.not_period_complete!
|
71
|
+
raise 'Not period complete'
|
72
|
+
end
|
73
|
+
|
74
|
+
def <=>(slice_collection)
|
75
|
+
(size == slice_collection.slices.size) && all? { |slice| slice_collection.include?(slice) }
|
76
|
+
end
|
77
|
+
|
78
|
+
def method_missing(_method, *_args)
|
79
|
+
raise SlicedConceptInterrupt
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
module Temporality
|
2
|
+
class TimeSpan
|
3
|
+
include Comparable
|
4
|
+
|
5
|
+
attr_accessor :starts_on, :ends_on
|
6
|
+
|
7
|
+
def initialize(starts_on, ends_on, _options = {})
|
8
|
+
@starts_on = starts_on
|
9
|
+
@ends_on = ends_on
|
10
|
+
|
11
|
+
check!
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_s
|
15
|
+
"{ TS : [#{starts_on} | #{ends_on}]}"
|
16
|
+
end
|
17
|
+
|
18
|
+
def inspect
|
19
|
+
to_s
|
20
|
+
end
|
21
|
+
|
22
|
+
# TODO : Refactorer éventuellement pour avoir un algo ~o(1) au lieu de o(n)
|
23
|
+
# Utiliser Date#ld comme base pour un algo ~o(1)
|
24
|
+
def day_count(excluded = [])
|
25
|
+
if excluded.empty?
|
26
|
+
1 + (@ends_on - @starts_on).to_i
|
27
|
+
else
|
28
|
+
(@starts_on..@ends_on).inject(0) { |c, day| c += excluded.include?(day.cwday) ? 0 : 1 }
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def calendaires
|
33
|
+
day_count
|
34
|
+
end
|
35
|
+
|
36
|
+
def ouvres
|
37
|
+
day_count [6, 7]
|
38
|
+
end
|
39
|
+
|
40
|
+
def ouvrables
|
41
|
+
day_count [7]
|
42
|
+
end
|
43
|
+
|
44
|
+
def covers?(time_span)
|
45
|
+
(@starts_on <= time_span.starts_on) && (@ends_on >= time_span.ends_on)
|
46
|
+
end
|
47
|
+
|
48
|
+
def ==(time_span)
|
49
|
+
@starts_on == time_span.starts_on && @ends_on == time_span.ends_on
|
50
|
+
end
|
51
|
+
|
52
|
+
def eql?(time_span)
|
53
|
+
self.==(time_span) && (self.class == time_span.class)
|
54
|
+
end
|
55
|
+
|
56
|
+
def <=>(time_span)
|
57
|
+
if @starts_on != time_span.starts_on
|
58
|
+
@starts_on <=> time_span.starts_on
|
59
|
+
else
|
60
|
+
@ends_on <=> time_span.ends_on
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def intersects?(time_span)
|
65
|
+
if time_span.respond_to?(:starts_on) && time_span.respond_to?(:ends_on)
|
66
|
+
(time_span.ends_on >= @starts_on) && (time_span.starts_on <= @ends_on)
|
67
|
+
elsif time_span.is_a?(TimeSpanCollection)
|
68
|
+
time_span.any? { |ts| intersects? ts }
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def intersect(time_span)
|
73
|
+
if time_span.is_a? TimeSpan
|
74
|
+
intersects?(time_span) ? TimeSpan.new([@starts_on, time_span.starts_on].max, [@ends_on, time_span.ends_on].min) : nil
|
75
|
+
elsif time_span.is_a? TimeSpanCollection
|
76
|
+
# byebug
|
77
|
+
r = time_span.map { |ts| intersect ts }.compact
|
78
|
+
r.size > 1 ? TimeSpanCollection.new(r) : r.first
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def substract(time_span)
|
83
|
+
if time_span.is_a?(TimeSpan)
|
84
|
+
if time_span.covers? self
|
85
|
+
nil
|
86
|
+
elsif !time_span.intersects? self
|
87
|
+
dup
|
88
|
+
elsif starts_on < time_span.starts_on && ends_on > time_span.ends_on
|
89
|
+
TimeSpanCollection.new([
|
90
|
+
TimeSpan.new(starts_on, time_span.starts_on - 1),
|
91
|
+
TimeSpan.new(time_span.ends_on + 1, ends_on)
|
92
|
+
])
|
93
|
+
elsif starts_on < time_span.starts_on && ends_on <= time_span.ends_on
|
94
|
+
TimeSpan.new(starts_on, time_span.starts_on - 1)
|
95
|
+
elsif starts_on >= time_span.starts_on && ends_on > time_span.ends_on
|
96
|
+
TimeSpan.new(time_span.ends_on + 1, ends_on)
|
97
|
+
else
|
98
|
+
raise "That's one fucked up edge case"
|
99
|
+
end
|
100
|
+
elsif time_span.is_a?(TimeSpanCollection)
|
101
|
+
TimeSpanCollection.new(
|
102
|
+
time_span.inject(dup) do |res, ts|
|
103
|
+
res.substract(ts)
|
104
|
+
end
|
105
|
+
)
|
106
|
+
else
|
107
|
+
raise TypeError, 'Wrong type supplied, should be TimeSpan or TimeSpanCollection'
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def intersect!(time_span)
|
112
|
+
@starts_on = [@starts_on, time_span.starts_on].max
|
113
|
+
@ends_on = [@ends_on, time_span.ends_on].min
|
114
|
+
check!
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def valid?
|
120
|
+
@starts_on <= @ends_on
|
121
|
+
end
|
122
|
+
|
123
|
+
def check!
|
124
|
+
raise "Invalid dates : #{self}" unless valid?
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'temporality/time_span'
|
2
|
+
|
3
|
+
module Temporality
|
4
|
+
class TimeSpanCollection < Array
|
5
|
+
def initialize(collection = [])
|
6
|
+
super()
|
7
|
+
collection = [collection].flatten # Support pour ne passer qu'un seul TS
|
8
|
+
collection.map { |ts| self << (ts.is_a?(TimeSpan) ? ts : raise(TypeError, "Element is not a Temporality::TimeSpan")) }
|
9
|
+
sort!
|
10
|
+
end
|
11
|
+
|
12
|
+
def to_s
|
13
|
+
"{TSC : #{map { |ts| ts.to_s }.join(", ")}}"
|
14
|
+
end
|
15
|
+
|
16
|
+
def inspect
|
17
|
+
to_s
|
18
|
+
end
|
19
|
+
|
20
|
+
def limit_to!(time_span)
|
21
|
+
reject! { |ts| !ts.intersects?(time_span) }
|
22
|
+
first.starts_on = [first.starts_on, time_span.starts_on].max
|
23
|
+
last.ends_on = [last.ends_on, time_span.ends_on].min
|
24
|
+
self
|
25
|
+
end
|
26
|
+
|
27
|
+
def intersect!(time_span)
|
28
|
+
if time_span.is_a? TimeSpan
|
29
|
+
map! { |ts| ts.intersect(time_span) }.compact!
|
30
|
+
elsif time_span.is_a? TimeSpanCollection
|
31
|
+
raise 'Not handling time_span collections yet'
|
32
|
+
else
|
33
|
+
raise TypeError, "Element is not a Temporality::TimeSpan or a Temporality::TimeSpanCollection"
|
34
|
+
end
|
35
|
+
|
36
|
+
self
|
37
|
+
end
|
38
|
+
|
39
|
+
def intersect(tsc)
|
40
|
+
res = tsc.inject(TimeSpanCollection.new) do |r, ts|
|
41
|
+
r << ts.intersect(self)
|
42
|
+
end.compact.sort
|
43
|
+
|
44
|
+
TimeSpanCollection.new(res)
|
45
|
+
end
|
46
|
+
|
47
|
+
def intersects?(time_span)
|
48
|
+
any? { |ts| ts.intersects? time_span }
|
49
|
+
end
|
50
|
+
|
51
|
+
def substract(time_span)
|
52
|
+
raise TypeError, "Not a TimeSpan" unless time_span.is_a? TimeSpan
|
53
|
+
|
54
|
+
TimeSpanCollection.new(
|
55
|
+
map do |ts|
|
56
|
+
ts.substract(time_span)
|
57
|
+
end
|
58
|
+
)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'temporality/violation'
|
2
|
+
|
3
|
+
require 'temporality/auto_close'
|
4
|
+
require 'temporality/completeness'
|
5
|
+
require 'temporality/overlap'
|
6
|
+
require 'temporality/inclusion'
|
7
|
+
|
8
|
+
module Temporality
|
9
|
+
module Validation
|
10
|
+
|
11
|
+
VALIDATIONS = {
|
12
|
+
inclusion: Inclusion,
|
13
|
+
prevent_overlap: Overlap,
|
14
|
+
completeness: Completeness,
|
15
|
+
auto_close: AutoClose
|
16
|
+
}
|
17
|
+
|
18
|
+
DEFAULTS = { inclusion: true, completeness: false, prevent_overlap: false, auto_close: false }
|
19
|
+
|
20
|
+
def valid?(*args, &block)
|
21
|
+
validate_temporality_contraints!
|
22
|
+
super(*args, &block)
|
23
|
+
end
|
24
|
+
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def validate_temporality_contraints!
|
29
|
+
validate_bounds_order
|
30
|
+
|
31
|
+
temporal_associations.each do |assoc, constraints|
|
32
|
+
constraints.select { |k,v| v }.keys.each do |constraint|
|
33
|
+
validate_constraint(assoc, constraint)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def validate_constraint(assoc, constraint)
|
39
|
+
VALIDATIONS[constraint].new(self, assoc).validate
|
40
|
+
end
|
41
|
+
|
42
|
+
def validate_bounds_order
|
43
|
+
if starts_on > ends_on
|
44
|
+
raise Temporality::Violation.new("Start date is after end date [#{starts_on} - #{ends_on}]")
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def temporal_associations
|
49
|
+
self.class.instance_variable_get(:@temporality) || {}
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Temporality
|
2
|
+
class ValidationStrategy
|
3
|
+
|
4
|
+
def initialize(model, assoc)
|
5
|
+
@model = model
|
6
|
+
@assoc = assoc
|
7
|
+
end
|
8
|
+
|
9
|
+
|
10
|
+
protected
|
11
|
+
|
12
|
+
def inverse
|
13
|
+
raise "Unable to validate temporality overlap for #{@model.class} without inverse for association '#{@assoc}'" unless inverse_name
|
14
|
+
@model.send(@assoc).send(inverse_name)
|
15
|
+
end
|
16
|
+
|
17
|
+
def inverse_name
|
18
|
+
@model.class.reflect_on_association(@assoc).send(:inverse_name)
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
data/lib/temporality.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
require 'temporality/version'
|
4
|
+
|
5
|
+
require 'temporality/slice_collection'
|
6
|
+
|
7
|
+
require 'temporality/validation'
|
8
|
+
require 'temporality/default_boundary_values'
|
9
|
+
require 'temporality/attribute_overrides'
|
10
|
+
require 'temporality/associations'
|
11
|
+
require 'temporality/schema'
|
12
|
+
require 'temporality/scopes'
|
13
|
+
require 'temporality/day_count'
|
14
|
+
|
15
|
+
module Temporality
|
16
|
+
|
17
|
+
# Used when no start date is defined
|
18
|
+
PAST_INFINITY = Date.new(1500, 1, 1)
|
19
|
+
|
20
|
+
# Used when no end date is defined
|
21
|
+
FUTURE_INFINITY = Date.new(5000, 1, 1)
|
22
|
+
|
23
|
+
PREPENDS = [ AttributeOverrides, Validation ]
|
24
|
+
EXTENDS = [ Associations, Scopes ]
|
25
|
+
INCLUDES = [ DefaultBoundaryValues ]
|
26
|
+
|
27
|
+
def self.included(base)
|
28
|
+
PREPENDS.each { |mod| base.prepend(mod) }
|
29
|
+
EXTENDS.each { |mod| base.extend(mod) }
|
30
|
+
INCLUDES.each { |mod| base.include(mod) }
|
31
|
+
|
32
|
+
ActiveRecord::Base.send(:include, DayCount)
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
|
metadata
ADDED
@@ -0,0 +1,192 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: temporality
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.4
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- David FRANCOIS
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-10-25 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rspec
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.1'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.1'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '10.3'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '10.3'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: yard
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0.8'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0.8'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: redcarpet
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.1'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.1'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: simplecov
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0.9'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0.9'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: coveralls
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0.7'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0.7'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: activerecord
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: sqlite3
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: byebug
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
description: Give records the ability to validate temporal constraints on themselves
|
140
|
+
and associations
|
141
|
+
email:
|
142
|
+
- david@paygun.fr
|
143
|
+
executables: []
|
144
|
+
extensions: []
|
145
|
+
extra_rdoc_files: []
|
146
|
+
files:
|
147
|
+
- LICENSE
|
148
|
+
- README.md
|
149
|
+
- lib/temporality.rb
|
150
|
+
- lib/temporality/associations.rb
|
151
|
+
- lib/temporality/attribute_overrides.rb
|
152
|
+
- lib/temporality/auto_close.rb
|
153
|
+
- lib/temporality/completeness.rb
|
154
|
+
- lib/temporality/day_count.rb
|
155
|
+
- lib/temporality/default_boundary_values.rb
|
156
|
+
- lib/temporality/inclusion.rb
|
157
|
+
- lib/temporality/overlap.rb
|
158
|
+
- lib/temporality/schema.rb
|
159
|
+
- lib/temporality/scopes.rb
|
160
|
+
- lib/temporality/slice.rb
|
161
|
+
- lib/temporality/slice_collection.rb
|
162
|
+
- lib/temporality/time_span.rb
|
163
|
+
- lib/temporality/time_span_collection.rb
|
164
|
+
- lib/temporality/validation.rb
|
165
|
+
- lib/temporality/validation_strategy.rb
|
166
|
+
- lib/temporality/version.rb
|
167
|
+
- lib/temporality/violation.rb
|
168
|
+
homepage: https://paygun.fr
|
169
|
+
licenses:
|
170
|
+
- MIT
|
171
|
+
metadata: {}
|
172
|
+
post_install_message:
|
173
|
+
rdoc_options: []
|
174
|
+
require_paths:
|
175
|
+
- lib
|
176
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
177
|
+
requirements:
|
178
|
+
- - ">="
|
179
|
+
- !ruby/object:Gem::Version
|
180
|
+
version: '0'
|
181
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
182
|
+
requirements:
|
183
|
+
- - ">="
|
184
|
+
- !ruby/object:Gem::Version
|
185
|
+
version: 1.3.6
|
186
|
+
requirements: []
|
187
|
+
rubyforge_project:
|
188
|
+
rubygems_version: 2.5.1
|
189
|
+
signing_key:
|
190
|
+
specification_version: 4
|
191
|
+
summary: Make records temporal
|
192
|
+
test_files: []
|