Skip to content

Commit 7acc71d

Browse files
committed
Updates to DateTime literal:
* 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`, `#day`, `#hours`, `#minutes`, and `#seconds` accessors.
1 parent e870313 commit 7acc71d

3 files changed

Lines changed: 248 additions & 60 deletions

File tree

lib/rdf/model/literal/date.rb

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,10 @@ 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
1610
FORMAT = '%Y-%m-%d'.freeze
1711

1812
##
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.
13+
# Internally, a `Date` is represented using a native `::DateTime` object at midnight. 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.
2014
#
2115
# @note If initialized using the `#to_datetime` method, time component is unchanged. Otherewise, it is set to 00:00 (midnight).
2216
#
@@ -28,7 +22,7 @@ def initialize(value, datatype: nil, lexical: nil, **options)
2822
@object = case
2923
when value.class == ::Date
3024
@zone = nil
31-
# Use noon as midpoint of the interval
25+
# Use midnight as midpoint of the interval
3226
::DateTime.parse(value.strftime('%FT00:00:00'))
3327
when value.respond_to?(:to_datetime)
3428
dt = value.to_datetime
@@ -42,7 +36,7 @@ def initialize(value, datatype: nil, lexical: nil, **options)
4236
else
4337
@zone = nil # No timezone
4438
end
45-
# Use noon as midpoint of the interval
39+
# Use midnight as midpoint of the interval
4640
::DateTime.parse("#{dt}T00:00:00#{@zone}")
4741
end rescue ::DateTime.new
4842
end
@@ -132,20 +126,16 @@ def timezone?
132126
# @raise [RangeError] if `zone < -14*60` or `zone > 14*60`
133127
# @see https://www.w3.org/TR/xpath-functions/#func-adjust-date-to-timezone
134128
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?
129+
zone = args.empty? ? '+00:00' : args.first
130+
if zone.nil?
144131
# Remove timezone component
145132
@object = ::DateTime.parse(@object.strftime('%F'))
146133
@zone = nil
147134
else
148-
# Adjust to
135+
md = zone.match(Literal::DateTime::ZONE_GRAMMAR) if zone
136+
raise ArgumentError,
137+
"expected #{zone.inspect} to be a xsd:dayTimeDuration or +/-HH:MM" unless md
138+
# Adjust to zone
149139
si, hr, mi = md[:si], md[:hr], md[:mi]
150140
si ||= '+'
151141
offset = hr.to_i * 60 + mi.to_i
@@ -195,7 +185,7 @@ def tz
195185
# @see https://www.w3.org/TR/xpath-functions/#func-timezone-from-dateTime
196186
def timezone
197187
if @zone
198-
md = @zone.match(ZONE_GRAMMAR)
188+
md = @zone.match(Literal::DateTime::ZONE_GRAMMAR)
199189
si, hr, mi = md[:si], md[:hr].to_i, md[:mi].to_i
200190
si = nil unless si == "-"
201191
res = "#{si}PT#{hr}H#{"#{mi}M" if mi > 0}"

lib/rdf/model/literal/datetime.rb

Lines changed: 179 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,43 @@ module RDF; class Literal
22
##
33
# A date/time literal.
44
#
5-
# @see http://www.w3.org/TR/xmlschema11-2/#dateTime#boolean
5+
# @see http://www.w3.org/TR/xmlschema11-2/#dateTime
66
# @since 0.2.1
77
class DateTime < Literal
88
DATATYPE = RDF::URI("http://www.w3.org/2001/XMLSchema#dateTime")
99
GRAMMAR = %r(\A(-?(?:\d{4}|[1-9]\d{4,})-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?)((?:[\+\-]\d{2}:\d{2})|UTC|GMT|Z)?\Z).freeze
10-
FORMAT = '%Y-%m-%dT%H:%M:%S.%L%:z'.freeze
10+
FORMAT = '%Y-%m-%dT%H:%M:%S.%L'.freeze
11+
12+
# Matches either -10:00 or -P1H0M forms
13+
ZONE_GRAMMAR = %r(\A
14+
(?:(?<si>[+-])(?<hr>\d{2}):(?:(?<mi>\d{2}))?)
15+
|(?:(?<si>-)?PT(?<hr>\d{1,2})H(?:(?<mi>\d{1,2})M)?)
16+
\z)x.freeze
1117

1218
##
19+
# Internally, a `DateTime` is represented using a native `::DateTime`. 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+
#
1321
# @param [DateTime] value
1422
# @option options [String] :lexical (nil)
1523
def initialize(value, datatype: nil, lexical: nil, **options)
1624
@datatype = RDF::URI(datatype || self.class.const_get(:DATATYPE))
1725
@string = lexical || (value if value.is_a?(String))
1826
@object = case
19-
when value.is_a?(::DateTime) then value
20-
when value.respond_to?(:to_datetime) then value.to_datetime
21-
else ::DateTime.parse(value.to_s)
27+
when value.is_a?(::DateTime)
28+
@zone = value.zone
29+
value
30+
when value.respond_to?(:to_datetime)
31+
@zone = value.to_datetime.zone
32+
value.to_datetime
33+
else
34+
md = value.to_s.match(GRAMMAR)
35+
_, dt, tz = Array(md)
36+
if tz
37+
@zone = tz == 'Z' ? '+00:00' : tz
38+
else
39+
@zone = nil # No timezone
40+
end
41+
::DateTime.parse(value.to_s)
2242
end rescue ::DateTime.new
2343
end
2444

@@ -29,12 +49,10 @@ def initialize(value, datatype: nil, lexical: nil, **options)
2949
# @return [RDF::Literal] `self`
3050
# @see http://www.w3.org/TR/xmlschema11-2/#dateTime
3151
def canonicalize!
32-
if self.valid?
33-
@string = if timezone?
34-
@object.new_offset.new_offset.strftime(FORMAT[0..-4] + 'Z').sub('.000', '')
35-
else
36-
@object.strftime(FORMAT[0..-4]).sub('.000', '')
37-
end
52+
if self.valid? && @zone && @zone != '+00:00'
53+
adjust_to_timezone!
54+
else
55+
@string = nil
3856
end
3957
self
4058
end
@@ -43,18 +61,32 @@ def canonicalize!
4361
# Returns the timezone part of arg as a simple literal. Returns the empty string if there is no timezone.
4462
#
4563
# @return [RDF::Literal]
46-
# @see http://www.w3.org/TR/sparql11-query/#func-tz
4764
def tz
48-
zone = timezone? ? object.zone : ""
49-
zone = "Z" if zone == "+00:00"
50-
RDF::Literal(zone)
65+
RDF::Literal(@zone == "+00:00" ? 'Z' : @zone)
5166
end
5267

68+
##
69+
# Does the literal representation include a timezone? Note that this is only possible if initialized using a string, or `:lexical` option.
70+
#
71+
# @return [Boolean]
72+
# @since 1.1.6
73+
def timezone?
74+
# Can only know there's a timezone from the string represntation
75+
md = to_s.match(GRAMMAR)
76+
md && !!md[2]
77+
end
78+
alias_method :tz?, :timezone?
79+
alias_method :has_tz?, :timezone?
80+
alias_method :has_timezone?, :timezone?
81+
5382
##
5483
# Returns the timezone part of arg as an xsd:dayTimeDuration, or `nil`
5584
# if lexical form of literal does not include a timezone.
5685
#
86+
# From [fn:timezone-from-date](https://www.w3.org/TR/xpath-functions/#func-timezone-from-date).
87+
#
5788
# @return [RDF::Literal]
89+
# @see https://www.w3.org/TR/xpath-functions/#func-timezone-from-date
5890
def timezone
5991
if tz == 'Z'
6092
RDF::Literal("PT0S", datatype: RDF::URI("http://www.w3.org/2001/XMLSchema#dayTimeDuration"))
@@ -86,33 +118,20 @@ def valid?
86118
# @return [Boolean]
87119
# @since 1.1.6
88120
def milliseconds?
89-
self.format("%L").to_i > 0
121+
object.strftime("%L").to_i > 0
90122
end
91123
alias_method :has_milliseconds?, :milliseconds?
92124
alias_method :has_ms?, :milliseconds?
93125
alias_method :ms?, :milliseconds?
94126

95-
##
96-
# Does the literal representation include a timezone? Note that this is only possible if initialized using a string, or `:lexical` option.
97-
#
98-
# @return [Boolean]
99-
# @since 1.1.6
100-
def timezone?
101-
md = self.to_s.match(GRAMMAR)
102-
md && !!md[2]
103-
end
104-
alias_method :tz?, :timezone?
105-
alias_method :has_tz?, :timezone?
106-
alias_method :has_timezone?, :timezone?
107-
108127
##
109128
# Returns the `timezone` of the literal. If the
110129
##
111130
# Returns the value as a string.
112131
#
113132
# @return [String]
114133
def to_s
115-
@string || @object.strftime(FORMAT).sub("+00:00", 'Z').sub('.000', '')
134+
@string || (@object.strftime(FORMAT).sub('.000', '') + self.tz)
116135
end
117136

118137
##
@@ -123,31 +142,153 @@ def to_s
123142
def humanize(lang = :en)
124143
d = object.strftime("%r on %A, %d %B %Y")
125144
if timezone?
126-
zone = if self.tz == 'Z'
127-
"UTC"
128-
else
129-
self.tz
130-
end
131-
d.sub!(" on ", " #{zone} on ")
145+
z = @zone == '+00:00' ? "UTC" : @zone
146+
d.sub!(" on ", " #{z} on ")
132147
end
133148
d
134149
end
135150

151+
##
152+
# Adjust the timezone.
153+
#
154+
# From [fn:adjust-dateTime-to-timezone](https://www.w3.org/TR/xpath-functions/#func-adjust-dateTime-to-timezone)
155+
#
156+
# @overload adjust_to_timezone!
157+
# Adjusts the timezone to UTC.
158+
#
159+
# @return [DateTime] `self`
160+
# @raise [RangeError] if `zone < -14*60` or `zone > 14*60`
161+
# @overload adjust_to_timezone!(zone)
162+
# If `zone` is nil, then the timzeone component is removed.
163+
#
164+
# Otherwise, the timezone is set based on the difference between the current timezone offset (if any) and `zone`.
165+
#
166+
# @param [String] zone (nil) In the form of {ZONE_FORMAT}
167+
# @return [DateTime] `self`
168+
# @raise [RangeError] if `zone < -14*60` or `zone > 14*60`
169+
# @see https://www.w3.org/TR/xpath-functions/#func-adjust-dateTime-to-timezone
170+
def adjust_to_timezone!(*args)
171+
zone = args.empty? ? '+00:00' : args.first
172+
if zone.nil?
173+
# Remove timezone component
174+
@object = ::DateTime.parse(@object.strftime(FORMAT))
175+
@zone = nil
176+
else
177+
md = zone.match(Literal::DateTime::ZONE_GRAMMAR) if zone
178+
raise ArgumentError,
179+
"expected #{zone.inspect} to be a xsd:dayTimeDuration or +/-HH:MM" unless md
180+
181+
# Adjust to zone
182+
si, hr, mi = md[:si], md[:hr], md[:mi]
183+
si ||= '+'
184+
offset = hr.to_i * 60 + mi.to_i
185+
raise ArgumentError,
186+
"Zone adjustment of #{zone} out of range" if
187+
md.nil? || offset > 14*60
188+
189+
new_zone = "%s%.2d:%.2d" % [si, hr.to_i, mi.to_i]
190+
dt = @zone.nil? ? @object : @object.new_offset(new_zone)
191+
@object = ::DateTime.parse(dt.strftime(FORMAT + new_zone))
192+
@zone = new_zone
193+
end
194+
@string = nil
195+
self
196+
end
197+
198+
##
199+
# Functional version of `#adjust_to_timezone!`.
200+
#
201+
# @overload adjust_to_timezone
202+
# @param (see #adjust_to_timezone!)
203+
# @return [DateTime]
204+
# @raise (see #adjust_to_timezone!)
205+
# @overload adjust_to_timezone(zone) (see #adjust_to_timezone!)
206+
# @return [DateTime]
207+
# @raise (see #adjust_to_timezone!)
208+
def adjust_to_timezone(*args)
209+
self.dup.adjust_to_timezone!(*args)
210+
end
211+
136212
##
137213
# Equal compares as DateTime objects
214+
#
215+
# From the XQuery function [op:dateTime-equal](https://www.w3.org/TR/xpath-functions/#func-dateTime-equal).
216+
#
217+
# @param [Literal::Date, Literal] other
218+
# @return [Boolean]
219+
# @see https://www.w3.org/TR/xpath-functions/#func-dateTime-equal
138220
def ==(other)
139221
# If lexically invalid, use regular literal testing
140-
return super unless self.valid?
222+
return super unless self.valid? && (!other.respond_to?(:valid?) || other.valid?)
141223

142224
case other
143225
when Literal::DateTime
144-
return super unless other.valid?
145226
self.object == other.object
146227
when Literal::Time, Literal::Date
147228
false
148229
else
149230
super
150231
end
151232
end
233+
234+
##
235+
# Compares `self` to `other` for sorting purposes (with type check).
236+
#
237+
# @param [Object] other
238+
# @return [Integer] `-1`, `0`, or `1`
239+
def <=>(other)
240+
# If lexically invalid, use regular literal testing
241+
return super unless self.valid? && (!other.respond_to?(:valid?) || other.valid?)
242+
return super unless other.is_a?(DateTime)
243+
@object <=> other.object
244+
end
245+
246+
# Years
247+
#
248+
# From the XQuery function [fn:year-from-dateTime](https://www.w3.org/TR/xpath-functions/#func-year-from-dateTime).
249+
#
250+
# @return [Integer]
251+
# @see https://www.w3.org/TR/xpath-functions/#func-year-from-dateTime
252+
def year; Integer.new(object.year); end
253+
254+
# Months
255+
#
256+
# From the XQuery function [fn:month-from-dateTime](https://www.w3.org/TR/xpath-functions/#func-month-from-dateTime).
257+
#
258+
# @return [Integer]
259+
# @see https://www.w3.org/TR/xpath-functions/#func-month-from-dateTime
260+
def month; Integer.new(object.month); end
261+
262+
# Days
263+
#
264+
# From the XQuery function [fn:day-from-dateTime](https://www.w3.org/TR/xpath-functions/#func-day-from-dateTime).
265+
#
266+
# @return [Integer]
267+
# @see https://www.w3.org/TR/xpath-functions/#func-day-from-dateTime
268+
def day; Integer.new(object.day); end
269+
270+
# Hours
271+
#
272+
# From the XQuery function [fn:hours-from-dateTime](https://www.w3.org/TR/xpath-functions/#func-hours-from-dateTime).
273+
#
274+
# @return [Integer]
275+
# @see https://www.w3.org/TR/xpath-functions/#func-hours-from-dateTime
276+
def hours; Integer.new(object.hour); end
277+
278+
# Minutes
279+
#
280+
# From the XQuery function [fn:minutes-from-dateTime](https://www.w3.org/TR/xpath-functions/#func-minutes-from-dateTime).
281+
#
282+
# @return [Integer]
283+
# @see https://www.w3.org/TR/xpath-functions/#func-minutes-from-dateTime
284+
def minutes; Integer.new(object.min); end
285+
286+
# Seconds
287+
#
288+
# From the XQuery function [fn:seconds-from-dateTime](https://www.w3.org/TR/xpath-functions/#func-seconds-from-dateTime).
289+
#
290+
# @return [Decimal]
291+
# @see https://www.w3.org/TR/xpath-functions/#func-seconds-from-dateTime
292+
def seconds; Decimal.new(object.strftime("%S.%L")); end
152293
end # DateTime
153294
end; end # RDF::Literal

0 commit comments

Comments
 (0)