Skip to content

Commit e870313

Browse files
committed
Updates to Date literal:
* Object base is now `::DateTime` instead of `::Date` to record and manipulate timezone. * Better conformance to XSD and XPath specs. * Adds `#adjust_to_timezone` (and `#adjust_to_timezone!`) based on XPath function. Takes either a `[+1]HH:MM` or an xsd:dayTimeDuration restricted to hours and minutes. * Adds `<=>` comparison operator. * Adds `#year`, `#month`, and `#day` accessors.
1 parent 0adae25 commit e870313

3 files changed

Lines changed: 427 additions & 81 deletions

File tree

lib/rdf/model/literal.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ def ==(other)
295295
when self.simple? && other.simple?
296296
self.value_hash == other.value_hash && self.value == other.value
297297
when other.comperable_datatype?(self) || self.comperable_datatype?(other)
298-
# Comoparing plain with undefined datatypes does not generate an error, but returns false
298+
# Comparing plain with undefined datatypes does not generate an error, but returns false
299299
# From data-r2/expr-equal/eq-2-2.
300300
false
301301
else

lib/rdf/model/literal/date.rb

Lines changed: 178 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,44 @@ module RDF; class Literal
77
class Date < Literal
88
DATATYPE = RDF::URI("http://www.w3.org/2001/XMLSchema#date")
99
GRAMMAR = %r(\A(-?\d{4}-\d{2}-\d{2})((?:[\+\-]\d{2}:\d{2})|UTC|GMT|Z)?\Z).freeze
10+
11+
# Matches either -10:00 or -P1H0M forms
12+
ZONE_GRAMMAR = %r(\A
13+
(?:(?<si>[+-])(?<hr>\d{2}):(?:(?<mi>\d{2}))?)
14+
|(?:(?<si>-)?PT(?<hr>\d{1,2})H(?:(?<mi>\d{1,2})M)?)
15+
\z)x.freeze
1016
FORMAT = '%Y-%m-%d'.freeze
1117

1218
##
13-
# @param [String, Date, #to_date] value
19+
# Internally, a `Date` is represented using a native `::DateTime` object at noon. If initialized from a `::Date`, there is no timezone component, If initialized from a `::DateTime`, the timezone is taken from that native object, otherwise, a timezone (or no timezone) is taken from the string representation having a matching `zzzzzz` component.
20+
#
21+
# @note If initialized using the `#to_datetime` method, time component is unchanged. Otherewise, it is set to 00:00 (midnight).
22+
#
23+
# @param [String, Date, #to_datetime] value
1424
# @param (see Literal#initialize)
1525
def initialize(value, datatype: nil, lexical: nil, **options)
1626
@datatype = RDF::URI(datatype || self.class.const_get(:DATATYPE))
1727
@string = lexical || (value if value.is_a?(String))
1828
@object = case
19-
when value.is_a?(::Date) then value
20-
when value.respond_to?(:to_date) then value.to_date
21-
else ::Date.parse(value.to_s)
22-
end rescue ::Date.new
29+
when value.class == ::Date
30+
@zone = nil
31+
# Use noon as midpoint of the interval
32+
::DateTime.parse(value.strftime('%FT00:00:00'))
33+
when value.respond_to?(:to_datetime)
34+
dt = value.to_datetime
35+
@zone = dt.zone
36+
dt
37+
else
38+
md = value.to_s.match(GRAMMAR)
39+
_, dt, tz = Array(md)
40+
if tz
41+
@zone = tz == 'Z' ? '+00:00' : tz
42+
else
43+
@zone = nil # No timezone
44+
end
45+
# Use noon as midpoint of the interval
46+
::DateTime.parse("#{dt}T00:00:00#{@zone}")
47+
end rescue ::DateTime.new
2348
end
2449

2550
##
@@ -30,7 +55,11 @@ def initialize(value, datatype: nil, lexical: nil, **options)
3055
# @return [RDF::Literal] `self`
3156
# @see http://www.w3.org/TR/xmlschema11-2/#date
3257
def canonicalize!
33-
@string = @object.strftime(FORMAT) + self.tz.to_s if self.valid?
58+
if self.valid? && @zone && @zone != '+00:00'
59+
adjust_to_timezone!
60+
else
61+
@string = nil
62+
end
3463
self
3564
end
3665

@@ -46,25 +75,12 @@ def valid?
4675
super && object && value !~ %r(\A0000)
4776
end
4877

49-
##
50-
# Does the literal representation include a timezone? Note that this is only possible if initialized using a string, or `:lexical` option.
51-
#
52-
# @return [Boolean]
53-
# @since 1.1.6
54-
def timezone?
55-
md = self.to_s.match(GRAMMAR)
56-
md && !!md[2]
57-
end
58-
alias_method :tz?, :timezone?
59-
alias_method :has_tz?, :timezone?
60-
alias_method :has_timezone?, :timezone?
61-
6278
##
6379
# Returns the value as a string.
6480
#
6581
# @return [String]
6682
def to_s
67-
@string || @object.strftime(FORMAT)
83+
@string || (@object.strftime(FORMAT) + self.tz)
6884
end
6985

7086
##
@@ -75,32 +91,131 @@ def to_s
7591
def humanize(lang = :en)
7692
d = object.strftime("%A, %d %B %Y")
7793
if timezone?
78-
d += if self.tz == 'Z'
94+
d += if @zone == '+00:00'
7995
" UTC"
8096
else
81-
" #{self.tz}"
97+
" #{@zone}"
8298
end
8399
end
84100
d
85101
end
86102

103+
##
104+
# Does the literal representation include a timezone? Note that this is only possible if initialized using a string, or `:lexical` option.
105+
#
106+
# @return [Boolean]
107+
# @since 1.1.6
108+
def timezone?
109+
!@zone.nil?
110+
end
111+
alias_method :tz?, :timezone?
112+
alias_method :has_tz?, :timezone?
113+
alias_method :has_timezone?, :timezone?
114+
115+
##
116+
# Adjust the timezone.
117+
#
118+
# From [fn:adjust-date-to-timezone](https://www.w3.org/TR/xpath-functions/#func-adjust-date-to-timezone)
119+
#
120+
# @overload adjust_to_timezone!
121+
# Adjusts the timezone to UTC.
122+
#
123+
# @return [Date] `self`
124+
# @raise [RangeError] if `zone < -14*60` or `zone > 14*60`
125+
# @overload adjust_to_timezone!(zone)
126+
# If `zone` is nil, then the timzeone component is removed.
127+
#
128+
# Otherwise, the timezone is set based on the difference between the current timezone offset (if any) and `zone`.
129+
#
130+
# @param [String] zone (nil) In the form of {ZONE_FORMAT}
131+
# @return [Date] `self`
132+
# @raise [RangeError] if `zone < -14*60` or `zone > 14*60`
133+
# @see https://www.w3.org/TR/xpath-functions/#func-adjust-date-to-timezone
134+
def adjust_to_timezone!(*args)
135+
zone = args.first
136+
md = zone.match(ZONE_GRAMMAR) if zone
137+
raise ArgumentError,
138+
"expected #{zone.inspect} to be a xsd:dayTimeDuration or +/-HH:MM" if
139+
zone && !md
140+
if args.empty?
141+
@object = ::DateTime.parse(@object.strftime('%F'))
142+
@zone = '+00:00'
143+
elsif zone.nil?
144+
# Remove timezone component
145+
@object = ::DateTime.parse(@object.strftime('%F'))
146+
@zone = nil
147+
else
148+
# Adjust to
149+
si, hr, mi = md[:si], md[:hr], md[:mi]
150+
si ||= '+'
151+
offset = hr.to_i * 60 + mi.to_i
152+
raise ArgumentError,
153+
"Zone adjustment of #{zone} out of range" if
154+
md.nil? || offset > 14*60
155+
156+
new_zone = "%s%.2d:%.2d" % [si, hr.to_i, mi.to_i]
157+
dt = @zone.nil? ? @object : @object.new_offset(new_zone)
158+
@object = ::DateTime.parse(dt.strftime("%FT00:00:00#{new_zone}"))
159+
@zone = new_zone
160+
end
161+
@string = nil
162+
self
163+
end
164+
165+
##
166+
# Functional version of `#adjust_to_timezone!`.
167+
#
168+
# @overload adjust_to_timezone
169+
# @param (see #adjust_to_timezone!)
170+
# @return [Date]
171+
# @raise (see #adjust_to_timezone!)
172+
# @overload adjust_to_timezone(zone) (see #adjust_to_timezone!)
173+
# @return [Date]
174+
# @raise (see #adjust_to_timezone!)
175+
def adjust_to_timezone(*args)
176+
self.dup.adjust_to_timezone!(*args)
177+
end
178+
87179
##
88180
# Returns the timezone part of arg as a simple literal. Returns the empty string if there is no timezone.
89181
#
90182
# @return [RDF::Literal]
91183
# @since 1.1.6
92184
def tz
93-
md = self.to_s.match(GRAMMAR)
94-
zone = md[2].to_s
95-
zone = "Z" if zone == "+00:00"
96-
RDF::Literal(zone)
185+
RDF::Literal(@zone == "+00:00" ? 'Z' : @zone)
97186
end
98187

188+
##
189+
# Returns the timezone part of arg as an xsd:dayTimeDuration, or `nil`
190+
# if lexical form of literal does not include a timezone.
191+
#
192+
# From [fn:timezone-from-dateTime](https://www.w3.org/TR/xpath-functions/#func-timezone-from-dateTime).
193+
#
194+
# @return [RDF::Literal]
195+
# @see https://www.w3.org/TR/xpath-functions/#func-timezone-from-dateTime
196+
def timezone
197+
if @zone
198+
md = @zone.match(ZONE_GRAMMAR)
199+
si, hr, mi = md[:si], md[:hr].to_i, md[:mi].to_i
200+
si = nil unless si == "-"
201+
res = "#{si}PT#{hr}H#{"#{mi}M" if mi > 0}"
202+
RDF::Literal(res, datatype: RDF::URI("http://www.w3.org/2001/XMLSchema#dayTimeDuration"))
203+
end
204+
end
205+
206+
##
207+
# Updates the date to a new timezone, or no timezone.
99208
##
100209
# Equal compares as Date objects
210+
#
211+
# From the XQuery function [op:date-equal](https://www.w3.org/TR/xpath-functions/#func-date-equal).
212+
#
213+
# @param [Date, Literal] other
214+
# @return [Boolean]
215+
# @see https://www.w3.org/TR/xpath-functions/#func-date-equal
101216
def ==(other)
102217
# If lexically invalid, use regular literal testing
103-
return super unless self.valid?
218+
return super unless self.valid? && (!other.respond_to?(:valid?) || other.valid?)
104219

105220
case other
106221
when Literal::Date
@@ -112,5 +227,41 @@ def ==(other)
112227
super
113228
end
114229
end
230+
231+
##
232+
# Compares `self` to `other` for sorting purposes (with type check).
233+
#
234+
# @param [Object] other
235+
# @return [Integer] `-1`, `0`, or `1`
236+
def <=>(other)
237+
# If lexically invalid, use regular literal testing
238+
return super unless self.valid? && (!other.respond_to?(:valid?) || other.valid?)
239+
return super unless other.is_a?(Date)
240+
@object <=> other.object
241+
end
242+
243+
# Years
244+
#
245+
# From the XQuery function [fn:year-from-date](https://www.w3.org/TR/xpath-functions/#func-year-from-date).
246+
#
247+
# @return [Integer]
248+
# @see https://www.w3.org/TR/xpath-functions/#func-year-from-date
249+
def year; Integer.new(object.year); end
250+
251+
# Months
252+
#
253+
# From the XQuery function [fn:month-from-date](https://www.w3.org/TR/xpath-functions/#func-month-from-date).
254+
#
255+
# @return [Integer]
256+
# @see https://www.w3.org/TR/xpath-functions/#func-month-from-date
257+
def month; Integer.new(object.month); end
258+
259+
# Days
260+
#
261+
# From the XQuery function [fn:day-from-date](https://www.w3.org/TR/xpath-functions/#func-day-from-date).
262+
#
263+
# @return [Integer]
264+
# @see https://www.w3.org/TR/xpath-functions/#func-day-from-date
265+
def day; Integer.new(object.day); end
115266
end # Date
116267
end; end # RDF::Literal

0 commit comments

Comments
 (0)