Class: ActiveSanction::PartialDate

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/active_sanction/partial_date.rb,
lib/active_sanction/partial_date/parser.rb

Overview

A date a sanctions list published imprecisely. Date cannot hold one: collapsing "1972" to 1972-01-01 invents a precision the publisher never claimed, and a scorer that believes it will call 1972 and 1972-04-29 a conflict when they are in fact a match.

PartialDate.parse("1972")                     # year only
PartialDate.parse("circa 1962")               # approximate
PartialDate.parse("between 1971 and 1973")    # a span
PartialDate.new(year: 1965, month: 4, day: 29)

Every instance carries a first and last possible date, which is what makes #overlaps? and #conflicts_with? exact regardless of how precise either side is. Instances are frozen on construction and compare by value.

Constant Summary collapse

PRECISIONS =

:range is a precision in the sense the scorer (#32) cares about -- how much of the calendar a date could be -- not a grammatical one.

T.let(%i[year month day range].freeze, T::Array[Symbol])

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(year: nil, month: nil, day: nil, from: nil, to: nil, approximate: false) ⇒ void

Untyped on purpose, and the same choice Entity makes: these arrive as whatever a publisher wrote and a parser made of it. What comes back out is typed -- see the readers above.

Parameters:

  • year (T.untyped) (defaults to: nil)
  • month (T.untyped) (defaults to: nil)
  • day (T.untyped) (defaults to: nil)
  • from (T.untyped) (defaults to: nil)
  • to (T.untyped) (defaults to: nil)
  • approximate (T.untyped) (defaults to: false)


109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/active_sanction/partial_date.rb', line 109

def initialize(year: nil, month: nil, day: nil, from: nil, to: nil, approximate: false)
  @approximate = T.let(approximate ? true : false, T::Boolean)
  @year = T.let(nil, T.nilable(Integer))
  @month = T.let(nil, T.nilable(Integer))
  @day = T.let(nil, T.nilable(Integer))
  @from = T.let(nil, T.nilable(PartialDate))
  @to = T.let(nil, T.nilable(PartialDate))
  # Both branches assign the members they own and hand back the pair of
  # dates that bound them, which is what makes those two non-nil for every
  # instance rather than for most of them.
  first, last =
    if from.nil? && to.nil?
      assign_point(year, month, day)
    else
      reject_mixed_shape(year, month, day)
      assign_range(from, to)
    end
  @first_date = T.let(first, Date)
  @last_date = T.let(last, Date)
  freeze
end

Instance Attribute Details

#approximate ⇒ Boolean (readonly)

Returns:

  • (Boolean)


65
66
67
# File 'lib/active_sanction/partial_date.rb', line 65

def approximate
  @approximate
end

#day ⇒ Integer? (readonly)

Returns:

  • (Integer, nil)


56
57
58
# File 'lib/active_sanction/partial_date.rb', line 56

def day
  @day
end

#first_date ⇒ Date (readonly)

The two dates that bound this one, whatever shape it is -- which is what makes #overlaps? exact regardless of how precise either side is. Never nil: #initialize derives both for every instance it will build.

Returns:

  • (Date)


71
72
73
# File 'lib/active_sanction/partial_date.rb', line 71

def first_date
  @first_date
end

#from ⇒ PartialDate? (readonly)

Returns:



59
60
61
# File 'lib/active_sanction/partial_date.rb', line 59

def from
  @from
end

#last_date ⇒ Date (readonly)

Returns:

  • (Date)


74
75
76
# File 'lib/active_sanction/partial_date.rb', line 74

def last_date
  @last_date
end

#month ⇒ Integer? (readonly)

Returns:

  • (Integer, nil)


53
54
55
# File 'lib/active_sanction/partial_date.rb', line 53

def month
  @month
end

#to ⇒ PartialDate? (readonly)

Returns:



62
63
64
# File 'lib/active_sanction/partial_date.rb', line 62

def to
  @to
end

#year ⇒ Integer? (readonly)

A point date carries year/month/day and no endpoints; a range carries its endpoints and no year of its own. Which is which is #range?.

Returns:

  • (Integer, nil)


50
51
52
# File 'lib/active_sanction/partial_date.rb', line 50

def year
  @year
end

Class Method Details

.from_h(hash) ⇒ T.attached_class

Rebuilds a date from #to_h output. Accepts string keys, so a record that has been through JSON round-trips without a separate coercion step.

Parameters:

  • hash (T.untyped)

Returns:

  • (T.attached_class)

Raises:



87
88
89
90
91
92
93
# File 'lib/active_sanction/partial_date.rb', line 87

def self.from_h(hash)
  attributes = hash.to_h.transform_keys(&:to_sym)
  unknown = attributes.keys - MEMBERS
  raise InvalidArgument, "unknown PartialDate attribute(s): #{unknown.join(", ")}" if unknown.any?

  new(**attributes)
end

.parse(text) ⇒ PartialDate?

Reads a date expression from free text, returning nil on anything it cannot read. The vocabulary lives in Parser, which is where new source spellings get added.

Parameters:

  • text (T.untyped)

Returns:



80
81
82
# File 'lib/active_sanction/partial_date.rb', line 80

def self.parse(text)
  Parser.call(text)
end

.range(from, to, approximate: false) ⇒ T.attached_class

A span, from the UN's TYPE_OF_DATE = BETWEEN. Endpoints may be PartialDates, #to_h hashes, or strings this class can parse.

Parameters:

  • from (T.untyped)
  • to (T.untyped)
  • approximate (Boolean) (defaults to: false)

Returns:

  • (T.attached_class)


98
99
100
# File 'lib/active_sanction/partial_date.rb', line 98

def self.range(from, to, approximate: false)
  new(from: from, to: to, approximate: approximate)
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


195
196
197
198
199
# File 'lib/active_sanction/partial_date.rb', line 195

def ==(other)
  return false unless other.instance_of?(self.class)

  to_h == other.to_h
end

#approximate? ⇒ Boolean

Returns:

  • (Boolean)


135
# File 'lib/active_sanction/partial_date.rb', line 135

def approximate? = approximate

#comparison_range ⇒ Range<Date>

Widened by APPROXIMATE_SLACK_YEARS when the publisher said circa.

Returns:

  • (Range<Date>)


175
176
177
178
179
# File 'lib/active_sanction/partial_date.rb', line 175

def comparison_range
  return to_range unless approximate?

  first_date.prev_year(APPROXIMATE_SLACK_YEARS)..last_date.next_year(APPROXIMATE_SLACK_YEARS)
end

#conflicts_with?(other) ⇒ Boolean

The strict complement of #overlaps? for two known dates. A missing date is not a conflict -- nobody claimed anything to contradict -- so nil answers false to both questions.

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


167
168
169
170
171
# File 'lib/active_sanction/partial_date.rb', line 167

def conflicts_with?(other)
  return false if other.nil?

  !overlaps?(other)
end

#hash ⇒ Integer

Returns:

  • (Integer)


203
204
205
# File 'lib/active_sanction/partial_date.rb', line 203

def hash
  [self.class, to_h].hash
end

#inspect ⇒ String

Returns:

  • (String)


208
209
210
# File 'lib/active_sanction/partial_date.rb', line 208

def inspect
  "#<#{self.class} #{self} precision=#{precision.inspect}>"
end

#overlaps?(other) ⇒ Boolean

True when the two dates could describe the same day. Precision does not have to match: a year-only date overlaps every full date inside it, which is what lets #32 treat 1972 against 1972-04-29 as a moderate boost rather than a miss.

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


155
156
157
158
159
160
161
# File 'lib/active_sanction/partial_date.rb', line 155

def overlaps?(other)
  return false if other.nil?

  mine = comparison_range
  theirs = comparable!(other).comparison_range
  mine.first <= theirs.last && theirs.first <= mine.last
end

#precision ⇒ Symbol

Returns:

  • (Symbol)


138
139
140
141
142
143
144
# File 'lib/active_sanction/partial_date.rb', line 138

def precision
  return :range if range?
  return :day if day
  return :month if month

  :year
end

#range? ⇒ Boolean

Returns:

  • (Boolean)


132
# File 'lib/active_sanction/partial_date.rb', line 132

def range? = !from.nil?

#to_h ⇒ Hash{Symbol => T.untyped}

Returns:

  • (Hash{Symbol => T.untyped})


182
183
184
# File 'lib/active_sanction/partial_date.rb', line 182

def to_h
  { year: year, month: month, day: day, from: from&.to_h, to: to&.to_h, approximate: approximate }
end

#to_range ⇒ Range<Date>

Every date this could be, which is the whole point of the type.

Returns:

  • (Range<Date>)


148
# File 'lib/active_sanction/partial_date.rb', line 148

def to_range = first_date..last_date

#to_s ⇒ String

Renders in a form .parse reads back, so a date survives a trip through free text -- which is how OFAC publishes them in the first place.

Returns:

  • (String)


189
190
191
192
# File 'lib/active_sanction/partial_date.rb', line 189

def to_s
  text = range? ? "#{from} to #{to}" : point_to_s
  approximate? ? "circa #{text}" : text
end