Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Strict Matching #29

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,32 @@ next_time = cron_parser.next(Time.now)

# Last occurrence
most_recent_time = cron_parser.last(Time.now)

# next 5 occurences
next_times = cron_parser.next(Time.now,5)

# last 5 occurences
previous_times = cron_parser.last(Time.now,5)
```

## Strict Matching AKA Friday the 13th matching

From the crontab manpage:
```
If both fields are restricted (i.e., aren't '*'), the command will be run when either field matches the current time. For example, ``30 4 1,15 * 5'' would cause a command to be run at 4:30 am on the 1st and 15th of each month, plus every Friday.
```

While this is the designed behavior, Some might not find it to be desireable or expected. If you want to match based on Day of Week AND Day of month, you can turn on strict matching with a parameter when you initialize.

Let's take a look at the difference between the two modes.

```
# Normal behavior
CronParser.new('0 1 13 * 5', Time).next(Time.now, 4)
=> [Fri 25 Mar 2016, Fri 01 Apr 2016, Fri 08 Apr 2016, Wed 13 Apr 2016]

# Strict Matching
CronParser.new('0 1 13 * 5', Time,true).next(Time.now, 4)
=> [Fri 13 May 2016, Fri 13 Jan 2017, Fri 13 Oct 2017, Fri, 13 Apr 2018]

```
61 changes: 41 additions & 20 deletions lib/cron_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ def initialize(time,time_source = Time)
@day = time.day
@hour = time.hour
@min = time.min

@time_source = time_source
end

Expand Down Expand Up @@ -51,8 +50,9 @@ def inspect
"sat" => "6"
}

def initialize(source,time_source = Time)
def initialize(source,time_source = Time, strict_match = false)
@source = interpret_vixieisms(source)
set_strict_match(strict_match)
@time_source = time_source
validate_source
end
Expand Down Expand Up @@ -80,7 +80,6 @@ def interpret_vixieisms(spec)
# returns the next occurence after the given date
def next(now = @time_source.now, num = 1)
t = InternalTime.new(now, @time_source)

unless time_specs[:month][0].include?(t.month)
nudge_month(t)
t.day = 0
Expand All @@ -91,7 +90,7 @@ def next(now = @time_source.now, num = 1)
t.hour = -1
end

unless time_specs[:hour][0].include?(t.hour)
unless time_specs[:hour][0].include?(t.hour)
nudge_hour(t)
t.min = -1
end
Expand Down Expand Up @@ -184,19 +183,24 @@ def interpolate_weekdays_without_cache(year, month)

# Careful, if both DOW and DOM fields are non-wildcard,
# then we only need to match *one* for cron to run the job:
if not (mday_field == '*' and wday_field == '*')
valid_mday = [] if mday_field == '*'
valid_wday = [] if wday_field == '*'
unless strict_match?
unless (mday_field == '*' and wday_field == '*')
valid_mday = [] if mday_field == '*'
valid_wday = [] if wday_field == '*'
end
end
# Careful: crontabs may use either 0 or 7 for Sunday:
valid_wday << 0 if valid_wday.include?(7)

result = []
while t.month == month
result << t.mday if valid_mday.include?(t.mday) || valid_wday.include?(t.wday)
if strict_match?
result << t.mday if (valid_mday.include?(t.mday) && valid_wday.include?(t.wday))
else
result << t.mday if (valid_mday.include?(t.mday) || valid_wday.include?(t.wday))
end
t = t.succ
end

[Set.new(result), result]
end

Expand All @@ -208,39 +212,46 @@ def nudge_month(t, dir = :next)
spec = time_specs[:month][1]
next_value = find_best_next(t.month, spec, dir)
t.month = next_value || (dir == :next ? spec.first : spec.last)
if strict_match?
until date_valid?(t)
next_value = find_best_next(t.month, spec, dir)
t.month = next_value || (dir == :next ? spec.first : spec.last)
nudge_year(t, dir) if next_value.nil?
valid_days = interpolate_weekdays(t.year, t.month)[1]
t.day = dir == :next ? valid_days.first : valid_days.last
end
else
nudge_year(t, dir) if next_value.nil?
# we changed the month, so its likely that the date is incorrect now
valid_days = interpolate_weekdays(t.year, t.month)[1]
t.day = dir == :next ? valid_days.first : valid_days.last
end

nudge_year(t, dir) if next_value.nil?

# we changed the month, so its likely that the date is incorrect now
valid_days = interpolate_weekdays(t.year, t.month)[1]
t.day = dir == :next ? valid_days.first : valid_days.last
end

def date_valid?(t, dir = :next)
interpolate_weekdays(t.year, t.month)[0].include?(t.day)
weekdays = interpolate_weekdays(t.year, t.month)[0]
weekdays.include?(t.day)
end

def nudge_date(t, dir = :next, can_nudge_month = true)
spec = interpolate_weekdays(t.year, t.month)[1]
next_value = find_best_next(t.day, spec, dir)
t.day = next_value || (dir == :next ? spec.first : spec.last)

nudge_month(t, dir) if next_value.nil? && can_nudge_month
end

def nudge_hour(t, dir = :next)
spec = time_specs[:hour][1]
next_value = find_best_next(t.hour, spec, dir)
t.hour = next_value || (dir == :next ? spec.first : spec.last)

nudge_date(t, dir) if next_value.nil?
end

def nudge_minute(t, dir = :next)
spec = time_specs[:minute][1]
next_value = find_best_next(t.min, spec, dir)
t.min = next_value || (dir == :next ? spec.first : spec.last)

nudge_hour(t, dir) if next_value.nil?
end

Expand All @@ -267,10 +278,8 @@ def substitute_parse_symbols(str)

def stepped_range(rng, step = 1)
len = rng.last - rng.first

num = len.div(step)
result = (0..num).map { |i| rng.first + step * i }

result.pop if result[-1] == rng.last and rng.exclude_end?
result
end
Expand All @@ -295,4 +304,16 @@ def validate_source
raise ArgumentError, 'not a valid cronline'
end
end

def strict_match?
@strict_match
end

def set_strict_match(strict_match = false)
@strict_match = false
if strict_match && [time_specs[:dom].last, time_specs[:dow].last].all?{|v| v != '*'}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that check even needed? I mean if the user tells us to use strict matching, why would we refuse that? I'd rather do:

  attr_accessor :strict_match
  alias_method :strict_match?, :strict_match

This would even get rid of the predicate method

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually yes; when I did not have this check in place, other specs failed that should not have. I'll admit I only 60-70% get whats actually going on with the code, so maybe you'd know better than I why. or, maybe it was from something else I later undid. I'll see what happens when i remove it again.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I just notifed when reading through the whole file that this beast is in serious need of a refactoring.

My plan yould be to chunk it up into understandable pieces, like having a CronParser::Default and CronParser::Strict class. But for that, lots of code would need to be changed, so I would do that after incorporating your changes.

@strict_match = true
end
end

end
Loading