active_date_range 0.5.1 → 0.5.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca443decbae6f02f3b89ec4177942c925eb20fb00fe70b809d96d0573a4d332a
4
- data.tar.gz: 915e82bd0b6449839876d7a193ad2430bd76bb810bedf31cd8b45c6c0abdabbd
3
+ metadata.gz: e0b596b6208103ad08949d24191796250a8df396ed0f2536b35578bb250781b3
4
+ data.tar.gz: 9ade9b1b706a11f9892353a63f9acb61c5e537602de67954c37434f24e0c23bb
5
5
  SHA512:
6
- metadata.gz: ff38e2d4be8755802f6ef51cb487366bd81341dd62948b38e463f6c583920fd5c4ec344052f3d9a0588bd60c8d04b25a12a41c8f0020dca3f80d879daea59120
7
- data.tar.gz: e9dc0f34eca52082773c9c6fa2a4609af3955a4731c56ded61a5d94af357185348f98b724b029eb4b5e31e08469c27c6f860e7b07bd4ef712a49cf42be6247a3
6
+ metadata.gz: 1a5ff4a0c6b173751d60f2cc959b614e59216e2c17085556ffe34674596ed74bb776882fcc576f7da8ac20e749d8a1aa9a740210e6da3dad85efb4f93876b086
7
+ data.tar.gz: 5960b5d080937c6ef690aefb4b6f758035c93a2d483ded6370307253526c69ccf72bfd0398e8f441610a8a2ced1520e860f331b3782e87441b3b05c5c10ffa72
data/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## 0.5.2
2
+
3
+ * Add calendar-aware `DateRangeValidator` for ActiveModel validations. Unlike `validates_length_of` which compares day counts (causing `1.month` to use ~30.44 day averages), this validator uses date arithmetic so that February (28 or 29 days) correctly satisfies `minimum_duration: 1.month`:
4
+
5
+ ```ruby
6
+ validates :period, date_range: { minimum_duration: 1.month }
7
+ validates :period, date_range: { maximum_duration: 1.year }
8
+ validates :period, date_range: { exact_duration: 3.months }
9
+ validates :period, date_range: { duration: 1.month..1.year }
10
+ validates :period, date_range: { bounded: true }
11
+ validates :period, date_range: { full_periods_of: :month }
12
+ validates :period, date_range: { starts_on: :beginning_of_month }
13
+ validates :period, date_range: { ends_on: :end_of_month }
14
+ validates :period, date_range: { covers: -> { Date.today } }
15
+ ```
16
+
17
+ *Edwin Vlieg*
18
+
1
19
  ## 0.5.1
2
20
 
3
21
  * Return `Float::INFINITY` from `size` for boundless ranges, making `validates_length_of` correctly reject them.
data/Gemfile.lock CHANGED
@@ -1,16 +1,16 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- active_date_range (0.5.1)
4
+ active_date_range (0.5.2)
5
5
  activesupport (~> 8.0)
6
6
  i18n (~> 1.6)
7
7
 
8
8
  GEM
9
9
  remote: https://rubygems.org/
10
10
  specs:
11
- actionpack (8.0.3)
12
- actionview (= 8.0.3)
13
- activesupport (= 8.0.3)
11
+ actionpack (8.1.2)
12
+ actionview (= 8.1.2)
13
+ activesupport (= 8.1.2)
14
14
  nokogiri (>= 1.8.5)
15
15
  rack (>= 2.2.4)
16
16
  rack-session (>= 1.0.1)
@@ -18,22 +18,22 @@ GEM
18
18
  rails-dom-testing (~> 2.2)
19
19
  rails-html-sanitizer (~> 1.6)
20
20
  useragent (~> 0.16)
21
- actionview (8.0.3)
22
- activesupport (= 8.0.3)
21
+ actionview (8.1.2)
22
+ activesupport (= 8.1.2)
23
23
  builder (~> 3.1)
24
24
  erubi (~> 1.11)
25
25
  rails-dom-testing (~> 2.2)
26
26
  rails-html-sanitizer (~> 1.6)
27
- activemodel (8.0.3)
28
- activesupport (= 8.0.3)
29
- activesupport (8.0.3)
27
+ activemodel (8.1.2)
28
+ activesupport (= 8.1.2)
29
+ activesupport (8.1.2)
30
30
  base64
31
- benchmark (>= 0.3)
32
31
  bigdecimal
33
32
  concurrent-ruby (~> 1.0, >= 1.3.1)
34
33
  connection_pool (>= 2.2.5)
35
34
  drb
36
35
  i18n (>= 1.6, < 2)
36
+ json
37
37
  logger (>= 1.4.2)
38
38
  minitest (>= 5.1)
39
39
  securerandom (>= 0.3)
@@ -41,91 +41,91 @@ GEM
41
41
  uri (>= 0.13.1)
42
42
  ast (2.4.3)
43
43
  base64 (0.3.0)
44
- benchmark (0.4.1)
45
44
  benchmark-ips (2.14.0)
46
- bigdecimal (3.3.0)
45
+ bigdecimal (4.0.1)
47
46
  builder (3.3.0)
48
47
  coderay (1.1.3)
49
- concurrent-ruby (1.3.5)
50
- connection_pool (2.5.4)
48
+ concurrent-ruby (1.3.6)
49
+ connection_pool (3.0.2)
51
50
  crass (1.0.6)
52
- date (3.4.1)
51
+ date (3.5.1)
53
52
  drb (2.2.3)
54
- erb (5.0.3)
53
+ erb (6.0.1)
55
54
  erubi (1.13.1)
56
- ffi (1.17.2)
57
- ffi (1.17.2-x86_64-linux-gnu)
58
- formatador (1.2.1)
55
+ ffi (1.17.3)
56
+ ffi (1.17.3-x86_64-linux-gnu)
57
+ formatador (1.2.3)
59
58
  reline
60
- guard (2.19.1)
59
+ guard (2.20.1)
61
60
  formatador (>= 0.2.4)
62
61
  listen (>= 2.7, < 4.0)
63
62
  logger (~> 1.6)
64
63
  lumberjack (>= 1.0.12, < 2.0)
65
64
  nenv (~> 0.1)
66
65
  notiffany (~> 0.0)
67
- ostruct (~> 0.6)
68
66
  pry (>= 0.13.0)
69
67
  shellany (~> 0.0)
70
68
  thor (>= 0.18.1)
71
69
  guard-compat (1.2.1)
72
- guard-minitest (2.4.6)
70
+ guard-minitest (3.0.0)
73
71
  guard-compat (~> 1.2)
74
- minitest (>= 3.0)
75
- i18n (1.14.7)
72
+ minitest (>= 5.0.4, < 7.0)
73
+ i18n (1.14.8)
76
74
  concurrent-ruby (~> 1.0)
77
- io-console (0.8.1)
78
- irb (1.15.2)
75
+ io-console (0.8.2)
76
+ irb (1.17.0)
79
77
  pp (>= 0.6.0)
78
+ prism (>= 1.3.0)
80
79
  rdoc (>= 4.0.0)
81
80
  reline (>= 0.4.2)
82
- json (2.15.1)
81
+ json (2.18.1)
83
82
  language_server-protocol (3.17.0.5)
84
83
  lint_roller (1.1.0)
85
- listen (3.9.0)
84
+ listen (3.10.0)
85
+ logger
86
86
  rb-fsevent (~> 0.10, >= 0.10.3)
87
87
  rb-inotify (~> 0.9, >= 0.9.10)
88
88
  logger (1.7.0)
89
- loofah (2.24.1)
89
+ loofah (2.25.0)
90
90
  crass (~> 1.0.2)
91
91
  nokogiri (>= 1.12.0)
92
92
  lumberjack (1.4.2)
93
93
  memory_profiler (1.1.0)
94
94
  method_source (1.1.0)
95
95
  mini_portile2 (2.8.9)
96
- minitest (5.25.5)
96
+ minitest (5.27.0)
97
97
  nenv (0.3.0)
98
- nokogiri (1.18.10)
98
+ nokogiri (1.19.1)
99
99
  mini_portile2 (~> 2.8.2)
100
100
  racc (~> 1.4)
101
- nokogiri (1.18.10-x86_64-linux-gnu)
101
+ nokogiri (1.19.1-x86_64-linux-gnu)
102
102
  racc (~> 1.4)
103
103
  notiffany (0.1.3)
104
104
  nenv (~> 0.1)
105
105
  shellany (~> 0.0)
106
- ostruct (0.6.3)
107
106
  parallel (1.27.0)
108
- parser (3.3.9.0)
107
+ parser (3.3.10.2)
109
108
  ast (~> 2.4.1)
110
109
  racc
111
110
  pp (0.6.3)
112
111
  prettyprint
113
112
  prettyprint (0.2.0)
114
- prism (1.5.1)
115
- pry (0.15.2)
113
+ prism (1.9.0)
114
+ pry (0.16.0)
116
115
  coderay (~> 1.1)
117
116
  method_source (~> 1.0)
118
- psych (5.2.6)
117
+ reline (>= 0.6.0)
118
+ psych (5.3.1)
119
119
  date
120
120
  stringio
121
121
  racc (1.8.1)
122
- rack (3.2.2)
122
+ rack (3.2.5)
123
123
  rack-session (2.1.1)
124
124
  base64 (>= 0.1.0)
125
125
  rack (>= 3.0.0)
126
126
  rack-test (2.2.0)
127
127
  rack (>= 1.3)
128
- rackup (2.2.1)
128
+ rackup (2.3.1)
129
129
  rack (>= 3)
130
130
  rails-dom-testing (2.3.0)
131
131
  activesupport (>= 5.0.0)
@@ -134,9 +134,9 @@ GEM
134
134
  rails-html-sanitizer (1.6.2)
135
135
  loofah (~> 2.21)
136
136
  nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
137
- railties (8.0.3)
138
- actionpack (= 8.0.3)
139
- activesupport (= 8.0.3)
137
+ railties (8.1.2)
138
+ actionpack (= 8.1.2)
139
+ activesupport (= 8.1.2)
140
140
  irb (~> 1.13)
141
141
  rackup (>= 1.0.0)
142
142
  rake (>= 12.2)
@@ -144,18 +144,18 @@ GEM
144
144
  tsort (>= 0.2)
145
145
  zeitwerk (~> 2.6)
146
146
  rainbow (3.1.1)
147
- rake (13.3.0)
147
+ rake (13.3.1)
148
148
  rb-fsevent (0.11.2)
149
149
  rb-inotify (0.11.1)
150
150
  ffi (~> 1.0)
151
- rdoc (6.15.0)
151
+ rdoc (7.2.0)
152
152
  erb
153
153
  psych (>= 4.0.0)
154
154
  tsort
155
155
  regexp_parser (2.11.3)
156
- reline (0.6.2)
156
+ reline (0.6.3)
157
157
  io-console (~> 0.5)
158
- rubocop (1.81.1)
158
+ rubocop (1.84.2)
159
159
  json (~> 2.3)
160
160
  language_server-protocol (~> 3.17.0.2)
161
161
  lint_roller (~> 1.1.0)
@@ -163,20 +163,20 @@ GEM
163
163
  parser (>= 3.3.0.2)
164
164
  rainbow (>= 2.2.2, < 4.0)
165
165
  regexp_parser (>= 2.9.3, < 3.0)
166
- rubocop-ast (>= 1.47.1, < 2.0)
166
+ rubocop-ast (>= 1.49.0, < 2.0)
167
167
  ruby-progressbar (~> 1.7)
168
168
  unicode-display_width (>= 2.4.0, < 4.0)
169
- rubocop-ast (1.47.1)
169
+ rubocop-ast (1.49.0)
170
170
  parser (>= 3.3.7.2)
171
- prism (~> 1.4)
171
+ prism (~> 1.7)
172
172
  rubocop-packaging (0.6.0)
173
173
  lint_roller (~> 1.1.0)
174
174
  rubocop (>= 1.72.1, < 2.0)
175
- rubocop-performance (1.26.0)
175
+ rubocop-performance (1.26.1)
176
176
  lint_roller (~> 1.1)
177
177
  rubocop (>= 1.75.0, < 2.0)
178
- rubocop-ast (>= 1.44.0, < 2.0)
179
- rubocop-rails (2.33.4)
178
+ rubocop-ast (>= 1.47.1, < 2.0)
179
+ rubocop-rails (2.34.3)
180
180
  activesupport (>= 4.2.0)
181
181
  lint_roller (~> 1.1)
182
182
  rack (>= 1.1)
@@ -185,17 +185,17 @@ GEM
185
185
  ruby-progressbar (1.13.0)
186
186
  securerandom (0.4.1)
187
187
  shellany (0.0.1)
188
- stringio (3.1.7)
189
- thor (1.4.0)
188
+ stringio (3.2.0)
189
+ thor (1.5.0)
190
190
  tsort (0.2.0)
191
191
  tzinfo (2.0.6)
192
192
  concurrent-ruby (~> 1.0)
193
193
  unicode-display_width (3.2.0)
194
194
  unicode-emoji (~> 4.1)
195
- unicode-emoji (4.1.0)
196
- uri (1.0.4)
195
+ unicode-emoji (4.2.0)
196
+ uri (1.1.1)
197
197
  useragent (0.16.11)
198
- zeitwerk (2.7.3)
198
+ zeitwerk (2.7.5)
199
199
 
200
200
  PLATFORMS
201
201
  ruby
@@ -39,3 +39,14 @@ en:
39
39
  long_quarter: "quarter %{quarter}"
40
40
  short_quarter: "Q%{quarter}"
41
41
  range: "%{begin} - %{end}"
42
+ errors:
43
+ messages:
44
+ not_a_date_range: "is not a valid date range"
45
+ unbounded: "must have both a start and end date"
46
+ duration_too_short: "is too short (minimum duration is %{duration})"
47
+ duration_too_long: "is too long (maximum duration is %{duration})"
48
+ wrong_duration: "has the wrong duration (should be %{duration})"
49
+ not_full_periods: "must consist of full %{period}s"
50
+ misaligned_start: "must start at the %{alignment}"
51
+ misaligned_end: "must end at the %{alignment}"
52
+ does_not_cover: "must include %{date}"
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDateRange
4
+ module Validators
5
+ class DateRangeValidator < ActiveModel::EachValidator
6
+ include ResolveValue
7
+
8
+ DURATION_OPTIONS = %i[minimum_duration maximum_duration exact_duration duration].freeze
9
+ GRANULARITY_MAP = {
10
+ month: :full_month?,
11
+ quarter: :full_quarter?,
12
+ year: :full_year?,
13
+ week: :full_week?
14
+ }.freeze
15
+ START_ALIGNMENT_MAP = {
16
+ beginning_of_month: :begin_at_beginning_of_month?,
17
+ beginning_of_quarter: :begin_at_beginning_of_quarter?,
18
+ beginning_of_year: :begin_at_beginning_of_year?,
19
+ beginning_of_week: :begin_at_beginning_of_week?
20
+ }.freeze
21
+ END_ALIGNMENT_MAP = {
22
+ end_of_month: ->(r) { r.end == r.end.at_end_of_month },
23
+ end_of_quarter: ->(r) { r.end == r.end.at_end_of_quarter },
24
+ end_of_year: ->(r) { r.end == r.end.at_end_of_year },
25
+ end_of_week: ->(r) { r.end == r.end.at_end_of_week }
26
+ }.freeze
27
+
28
+ def validate_each(record, attribute, value)
29
+ unless value.is_a?(ActiveDateRange::DateRange)
30
+ record.errors.add(attribute, :not_a_date_range, **options.except(*known_options))
31
+ return
32
+ end
33
+
34
+ validate_bounded(record, attribute, value)
35
+ validate_duration(record, attribute, value)
36
+ validate_full_periods_of(record, attribute, value)
37
+ validate_starts_on(record, attribute, value)
38
+ validate_ends_on(record, attribute, value)
39
+ validate_covers(record, attribute, value)
40
+ end
41
+
42
+ private
43
+ def known_options
44
+ DURATION_OPTIONS + %i[bounded full_periods_of starts_on ends_on covers message]
45
+ end
46
+
47
+ def validate_bounded(record, attribute, value)
48
+ return unless options[:bounded]
49
+
50
+ if value.boundless?
51
+ record.errors.add(attribute, :unbounded, **options.except(*known_options))
52
+ end
53
+ end
54
+
55
+ def validate_duration(record, attribute, value)
56
+ return if value.boundless?
57
+
58
+ if options[:duration]
59
+ range = resolve_value(record, options[:duration])
60
+ validate_minimum_duration(record, attribute, value, range.begin) if range.begin
61
+ validate_maximum_duration(record, attribute, value, range.end) if range.end
62
+ end
63
+
64
+ if options[:minimum_duration]
65
+ duration = resolve_value(record, options[:minimum_duration])
66
+ validate_minimum_duration(record, attribute, value, duration)
67
+ end
68
+
69
+ if options[:maximum_duration]
70
+ duration = resolve_value(record, options[:maximum_duration])
71
+ validate_maximum_duration(record, attribute, value, duration)
72
+ end
73
+
74
+ if options[:exact_duration]
75
+ duration = resolve_value(record, options[:exact_duration])
76
+ validate_exact_duration(record, attribute, value, duration)
77
+ end
78
+ end
79
+
80
+ def validate_minimum_duration(record, attribute, value, duration)
81
+ unless meets_minimum_duration?(value, duration)
82
+ record.errors.add(attribute, :duration_too_short,
83
+ duration: humanize_duration(duration),
84
+ **options.except(*known_options))
85
+ end
86
+ end
87
+
88
+ def validate_maximum_duration(record, attribute, value, duration)
89
+ unless meets_maximum_duration?(value, duration)
90
+ record.errors.add(attribute, :duration_too_long,
91
+ duration: humanize_duration(duration),
92
+ **options.except(*known_options))
93
+ end
94
+ end
95
+
96
+ def validate_exact_duration(record, attribute, value, duration)
97
+ unless meets_minimum_duration?(value, duration) && meets_maximum_duration?(value, duration)
98
+ record.errors.add(attribute, :wrong_duration,
99
+ duration: humanize_duration(duration),
100
+ **options.except(*known_options))
101
+ end
102
+ end
103
+
104
+ # Calendar-aware: Feb 1..Feb 28 with 1.month → begin + 1.month - 1.day = Feb 28 <= Feb 28 ✓
105
+ def meets_minimum_duration?(range, duration)
106
+ range.begin + duration - 1.day <= range.end
107
+ end
108
+
109
+ # Calendar-aware: Jan 1..Dec 31 with 1.year → begin + 1.year - 1.day = Dec 31 >= Dec 31 ✓
110
+ def meets_maximum_duration?(range, duration)
111
+ range.begin + duration - 1.day >= range.end
112
+ end
113
+
114
+ def validate_full_periods_of(record, attribute, value)
115
+ return unless options[:full_periods_of]
116
+
117
+ period = options[:full_periods_of].to_sym
118
+ method = GRANULARITY_MAP[period]
119
+
120
+ unless method && value.respond_to?(method) && value.public_send(method)
121
+ record.errors.add(attribute, :not_full_periods,
122
+ period: period,
123
+ **options.except(*known_options))
124
+ end
125
+ end
126
+
127
+ def validate_starts_on(record, attribute, value)
128
+ return unless options[:starts_on]
129
+
130
+ alignment = options[:starts_on].to_sym
131
+ method = START_ALIGNMENT_MAP[alignment]
132
+
133
+ unless method && value.respond_to?(method) && value.public_send(method)
134
+ record.errors.add(attribute, :misaligned_start,
135
+ alignment: alignment.to_s.tr("_", " "),
136
+ **options.except(*known_options))
137
+ end
138
+ end
139
+
140
+ def validate_ends_on(record, attribute, value)
141
+ return unless options[:ends_on]
142
+ return if value.end.nil?
143
+
144
+ alignment = options[:ends_on].to_sym
145
+ checker = END_ALIGNMENT_MAP[alignment]
146
+
147
+ unless checker&.call(value)
148
+ record.errors.add(attribute, :misaligned_end,
149
+ alignment: alignment.to_s.tr("_", " "),
150
+ **options.except(*known_options))
151
+ end
152
+ end
153
+
154
+ def validate_covers(record, attribute, value)
155
+ return unless options[:covers]
156
+
157
+ date = resolve_value(record, options[:covers])
158
+
159
+ unless value.cover?(date)
160
+ record.errors.add(attribute, :does_not_cover,
161
+ date: date,
162
+ **options.except(*known_options))
163
+ end
164
+ end
165
+
166
+ def humanize_duration(duration)
167
+ return duration.inspect unless duration.respond_to?(:parts)
168
+
169
+ duration.parts.map do |unit, amount|
170
+ label = amount == 1 ? unit.to_s.singularize : unit.to_s.pluralize
171
+ "#{amount} #{label}"
172
+ end.join(", ")
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDateRange
4
+ module Validators
5
+ module ResolveValue
6
+ private
7
+ def resolve_value(record, value)
8
+ case value
9
+ when Proc then value.arity == 0 ? value.call : value.call(record)
10
+ when Symbol then record.send(value)
11
+ else value
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_date_range/validators/resolve_value"
4
+ require "active_date_range/validators/date_range_validator"
5
+
6
+ # Register validator at top level so `validates :x, date_range: {}` works
7
+ ::DateRangeValidator = ActiveDateRange::Validators::DateRangeValidator
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveDateRange
4
- VERSION = "0.5.1"
4
+ VERSION = "0.5.2"
5
5
  end
@@ -12,6 +12,7 @@ require "active_date_range/version"
12
12
  require "active_date_range/date_range"
13
13
  require "active_date_range/humanizer"
14
14
  require "active_date_range/active_model_type"
15
+ require "active_date_range/validators" if defined?(ActiveModel)
15
16
 
16
17
  module ActiveDateRange
17
18
  class Error < StandardError; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_date_range
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Edwin Vlieg
@@ -139,6 +139,9 @@ files:
139
139
  - lib/active_date_range/humanizer.rb
140
140
  - lib/active_date_range/i18n.rb
141
141
  - lib/active_date_range/locale/en.yml
142
+ - lib/active_date_range/validators.rb
143
+ - lib/active_date_range/validators/date_range_validator.rb
144
+ - lib/active_date_range/validators/resolve_value.rb
142
145
  - lib/active_date_range/version.rb
143
146
  homepage: https://github.com/moneybird/active-date-range
144
147
  licenses: