jsonata 2.2.2.1
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 +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +4 -0
- data/.rubocop_local.yml +52 -0
- data/AGENTS.md +171 -0
- data/CHANGELOG.md +17 -0
- data/Gemfile +3 -0
- data/LICENSE +22 -0
- data/README.md +189 -0
- data/Rakefile +41 -0
- data/lib/jsonata/datetime.rb +1237 -0
- data/lib/jsonata/error.rb +113 -0
- data/lib/jsonata/evaluator.rb +1669 -0
- data/lib/jsonata/functions.rb +1505 -0
- data/lib/jsonata/parser.rb +1206 -0
- data/lib/jsonata/signature.rb +235 -0
- data/lib/jsonata/utils.rb +600 -0
- data/lib/jsonata/version.rb +5 -0
- data/lib/jsonata.rb +47 -0
- metadata +108 -0
|
@@ -0,0 +1,1237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# (c) Copyright IBM Corp. 2018 All Rights Reserved
|
|
4
|
+
# Project name: JSONata
|
|
5
|
+
# This project is licensed under the MIT License, see LICENSE
|
|
6
|
+
|
|
7
|
+
# Port of jsonata-js src/datetime.js
|
|
8
|
+
#
|
|
9
|
+
# DateTime formatting and parsing functions
|
|
10
|
+
# Implements the xpath-functions format-date-time specification
|
|
11
|
+
#
|
|
12
|
+
# JavaScript Date objects are represented by UTC Ruby Time objects.
|
|
13
|
+
|
|
14
|
+
module Jsonata
|
|
15
|
+
module DateTime
|
|
16
|
+
FEW = %w[Zero One Two Three Four Five Six Seven Eight Nine Ten
|
|
17
|
+
Eleven Twelve Thirteen Fourteen Fifteen Sixteen Seventeen Eighteen Nineteen].freeze
|
|
18
|
+
ORDINALS = %w[Zeroth First Second Third Fourth Fifth Sixth Seventh Eighth Ninth Tenth
|
|
19
|
+
Eleventh Twelfth Thirteenth Fourteenth Fifteenth Sixteenth Seventeenth Eighteenth Nineteenth].freeze
|
|
20
|
+
DECADES = %w[Twenty Thirty Forty Fifty Sixty Seventy Eighty Ninety Hundred].freeze
|
|
21
|
+
MAGNITUDES = %w[Thousand Million Billion Trillion].freeze
|
|
22
|
+
|
|
23
|
+
# converts a number into english words
|
|
24
|
+
# @param value - the value to format
|
|
25
|
+
# @param ordinal - ordinal or cardinal form
|
|
26
|
+
# @returns representation in words
|
|
27
|
+
def number_to_words(value, ordinal)
|
|
28
|
+
lookup = nil
|
|
29
|
+
lookup = lambda do |num, prev, ord|
|
|
30
|
+
words = +''
|
|
31
|
+
if num <= 19
|
|
32
|
+
words = (prev ? ' and ' : '') + (ord ? ORDINALS[num] : FEW[num])
|
|
33
|
+
elsif num < 100
|
|
34
|
+
tens = Utils.js_floor(num / 10.0)
|
|
35
|
+
remainder = num % 10
|
|
36
|
+
words = (prev ? ' and ' : '') + DECADES[tens - 2]
|
|
37
|
+
if remainder.positive?
|
|
38
|
+
words += "-#{lookup.call(remainder, false, ord)}"
|
|
39
|
+
elsif ord
|
|
40
|
+
words = "#{words[0...-1]}ieth"
|
|
41
|
+
end
|
|
42
|
+
elsif num < 1000
|
|
43
|
+
hundreds = Utils.js_floor(num / 100.0)
|
|
44
|
+
remainder = num % 100
|
|
45
|
+
words = "#{', ' if prev}#{FEW[hundreds]} Hundred"
|
|
46
|
+
if remainder.positive?
|
|
47
|
+
words += lookup.call(remainder, true, ord)
|
|
48
|
+
elsif ord
|
|
49
|
+
words += 'th'
|
|
50
|
+
end
|
|
51
|
+
else
|
|
52
|
+
mag = (Math.log10(num) / 3).floor
|
|
53
|
+
mag = MAGNITUDES.length if mag > MAGNITUDES.length # the largest word
|
|
54
|
+
factor = 10.0**(mag * 3)
|
|
55
|
+
mant = Utils.js_floor(num / factor)
|
|
56
|
+
remainder = Utils.normalize_number(num - (mant * factor))
|
|
57
|
+
words = "#{', ' if prev}#{lookup.call(mant, false, false)} #{MAGNITUDES[mag - 1]}"
|
|
58
|
+
if remainder.positive?
|
|
59
|
+
words += lookup.call(remainder, true, ord)
|
|
60
|
+
elsif ord
|
|
61
|
+
words += 'th'
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
words
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
lookup.call(value, false, ordinal)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
WORD_VALUES = {}.tap do |word_values|
|
|
71
|
+
FEW.each_with_index do |word, index|
|
|
72
|
+
word_values[word.downcase] = index
|
|
73
|
+
end
|
|
74
|
+
ORDINALS.each_with_index do |word, index|
|
|
75
|
+
word_values[word.downcase] = index
|
|
76
|
+
end
|
|
77
|
+
DECADES.each_with_index do |word, index|
|
|
78
|
+
lword = word.downcase
|
|
79
|
+
word_values[lword] = (index + 2) * 10
|
|
80
|
+
word_values["#{lword[0, word.length - 1]}ieth"] = word_values[lword]
|
|
81
|
+
end
|
|
82
|
+
word_values['hundredth'] = 100
|
|
83
|
+
MAGNITUDES.each_with_index do |word, index|
|
|
84
|
+
lword = word.downcase
|
|
85
|
+
val = 10**((index + 1) * 3)
|
|
86
|
+
word_values[lword] = val
|
|
87
|
+
word_values["#{lword}th"] = val
|
|
88
|
+
end
|
|
89
|
+
end.freeze
|
|
90
|
+
|
|
91
|
+
# Converts a number in english words to numeric value
|
|
92
|
+
# @param text - the number in words
|
|
93
|
+
# @returns the numeric value
|
|
94
|
+
def words_to_number(text)
|
|
95
|
+
parts = text.split(/,\s|\sand\s|[\s\\-]/, -1)
|
|
96
|
+
values = parts.map { |part| WORD_VALUES[part] }
|
|
97
|
+
segs = [0]
|
|
98
|
+
values.each do |value|
|
|
99
|
+
if !value.nil? && value < 100
|
|
100
|
+
top = segs.pop
|
|
101
|
+
if top >= 1000
|
|
102
|
+
segs.push(top)
|
|
103
|
+
top = 0
|
|
104
|
+
end
|
|
105
|
+
segs.push(top + value)
|
|
106
|
+
else
|
|
107
|
+
segs.push(value.nil? ? Float::NAN : segs.pop * value.to_f)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
Utils.normalize_number(segs.reduce(0) { |a, b| a + b })
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
ROMAN_NUMERALS = [
|
|
114
|
+
[1000, 'm'],
|
|
115
|
+
[900, 'cm'],
|
|
116
|
+
[500, 'd'],
|
|
117
|
+
[400, 'cd'],
|
|
118
|
+
[100, 'c'],
|
|
119
|
+
[90, 'xc'],
|
|
120
|
+
[50, 'l'],
|
|
121
|
+
[40, 'xl'],
|
|
122
|
+
[10, 'x'],
|
|
123
|
+
[9, 'ix'],
|
|
124
|
+
[5, 'v'],
|
|
125
|
+
[4, 'iv'],
|
|
126
|
+
[1, 'i']
|
|
127
|
+
].freeze
|
|
128
|
+
|
|
129
|
+
ROMAN_VALUES = {'M' => 1000, 'D' => 500, 'C' => 100, 'L' => 50, 'X' => 10, 'V' => 5, 'I' => 1}.freeze
|
|
130
|
+
|
|
131
|
+
# converts a number to roman numerals
|
|
132
|
+
# @param value - the number
|
|
133
|
+
# @returns the number in roman numerals
|
|
134
|
+
def decimal_to_roman(value)
|
|
135
|
+
ROMAN_NUMERALS.each do |numeral|
|
|
136
|
+
return numeral[1] + decimal_to_roman(value - numeral[0]) if value >= numeral[0]
|
|
137
|
+
end
|
|
138
|
+
''
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# converts roman numerals to a number
|
|
142
|
+
# @param roman - roman number
|
|
143
|
+
# @returns the numeric value
|
|
144
|
+
def roman_to_decimal(roman)
|
|
145
|
+
decimal = 0
|
|
146
|
+
max = 1
|
|
147
|
+
(roman.length - 1).downto(0) do |i|
|
|
148
|
+
digit = roman[i]
|
|
149
|
+
value = ROMAN_VALUES[digit]
|
|
150
|
+
if value < max
|
|
151
|
+
decimal -= value
|
|
152
|
+
else
|
|
153
|
+
max = value
|
|
154
|
+
decimal += value
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
decimal
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# converts a number to spreadsheet style letters
|
|
161
|
+
# @param value - the number
|
|
162
|
+
# @param a_char - the character representing the start of the sequence, e.g. 'A'
|
|
163
|
+
# @returns the letters
|
|
164
|
+
def decimal_to_letters(value, a_char)
|
|
165
|
+
letters = []
|
|
166
|
+
a_code = a_char.ord
|
|
167
|
+
while value.positive?
|
|
168
|
+
letters.unshift((((value - 1) % 26) + a_code).chr(Encoding::UTF_8))
|
|
169
|
+
value = ((value - 1) / 26).floor
|
|
170
|
+
end
|
|
171
|
+
letters.join
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# converts spreadsheet style letters to a number
|
|
175
|
+
# @param letters - the letters
|
|
176
|
+
# @param a_char - the character representing the start of the sequence, e.g. 'A'
|
|
177
|
+
# @returns the numeric value
|
|
178
|
+
def letters_to_decimal(letters, a_char)
|
|
179
|
+
a_code = a_char.ord
|
|
180
|
+
decimal = 0
|
|
181
|
+
letters.length.times do |i|
|
|
182
|
+
decimal += (letters[letters.length - i - 1].ord - a_code + 1) * (26**i)
|
|
183
|
+
end
|
|
184
|
+
decimal
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Formats an integer as specified by the XPath fn:format-integer function
|
|
188
|
+
# See https://www.w3.org/TR/xpath-functions-31/#func-format-integer
|
|
189
|
+
# @param value - the number to be formatted
|
|
190
|
+
# @param picture - the picture string that specifies the format
|
|
191
|
+
# @returns the formatted number
|
|
192
|
+
def format_integer(value, picture)
|
|
193
|
+
return nil if value.nil?
|
|
194
|
+
|
|
195
|
+
value = Utils.js_floor(value)
|
|
196
|
+
|
|
197
|
+
format = analyse_integer_picture(picture)
|
|
198
|
+
_format_integer(value, format)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
FORMATS = {
|
|
202
|
+
:DECIMAL => 'decimal',
|
|
203
|
+
:LETTERS => 'letters',
|
|
204
|
+
:ROMAN => 'roman',
|
|
205
|
+
:WORDS => 'words',
|
|
206
|
+
:SEQUENCE => 'sequence'
|
|
207
|
+
}.freeze
|
|
208
|
+
|
|
209
|
+
TCASE = {
|
|
210
|
+
:UPPER => 'upper',
|
|
211
|
+
:LOWER => 'lower',
|
|
212
|
+
:TITLE => 'title'
|
|
213
|
+
}.freeze
|
|
214
|
+
|
|
215
|
+
# formats an integer using a preprocessed representation of the picture string
|
|
216
|
+
# @param value - the number to be formatted
|
|
217
|
+
# @param format - the preprocessed representation of the pucture string
|
|
218
|
+
# @returns the formatted number
|
|
219
|
+
def _format_integer(value, format)
|
|
220
|
+
formatted_integer = nil
|
|
221
|
+
negative = value.negative?
|
|
222
|
+
value = value.abs
|
|
223
|
+
case format[:primary]
|
|
224
|
+
when FORMATS[:LETTERS]
|
|
225
|
+
formatted_integer = decimal_to_letters(value, format[:case] == TCASE[:UPPER] ? 'A' : 'a')
|
|
226
|
+
when FORMATS[:ROMAN]
|
|
227
|
+
formatted_integer = decimal_to_roman(value)
|
|
228
|
+
formatted_integer = formatted_integer.upcase if format[:case] == TCASE[:UPPER]
|
|
229
|
+
when FORMATS[:WORDS]
|
|
230
|
+
formatted_integer = number_to_words(value, format[:ordinal])
|
|
231
|
+
if format[:case] == TCASE[:UPPER]
|
|
232
|
+
formatted_integer = formatted_integer.upcase
|
|
233
|
+
elsif format[:case] == TCASE[:LOWER]
|
|
234
|
+
formatted_integer = formatted_integer.downcase
|
|
235
|
+
end
|
|
236
|
+
when FORMATS[:DECIMAL]
|
|
237
|
+
formatted_integer = Utils.number_to_s(value)
|
|
238
|
+
# TODO use functionPad
|
|
239
|
+
pad_length = format[:mandatory_digits] - formatted_integer.length
|
|
240
|
+
if pad_length.positive?
|
|
241
|
+
padding = '0' * pad_length
|
|
242
|
+
formatted_integer = padding + formatted_integer
|
|
243
|
+
end
|
|
244
|
+
if format[:zero_code] != 0x30
|
|
245
|
+
formatted_integer = Utils.string_to_array(formatted_integer).map do |code|
|
|
246
|
+
(code.ord + format[:zero_code] - 0x30).chr(Encoding::UTF_8)
|
|
247
|
+
end.join
|
|
248
|
+
end
|
|
249
|
+
# insert the grouping-separator-signs, if any
|
|
250
|
+
if format[:regular]
|
|
251
|
+
n = ((formatted_integer.length - 1) / format[:grouping_separators][:position]).floor
|
|
252
|
+
n.downto(1) do |ii|
|
|
253
|
+
pos = formatted_integer.length - (ii * format[:grouping_separators][:position])
|
|
254
|
+
formatted_integer = formatted_integer[0, pos] + format[:grouping_separators][:character] +
|
|
255
|
+
formatted_integer[pos..]
|
|
256
|
+
end
|
|
257
|
+
else
|
|
258
|
+
format[:grouping_separators].reverse_each do |separator|
|
|
259
|
+
pos = formatted_integer.length - separator[:position]
|
|
260
|
+
formatted_integer = formatted_integer[0, pos] + separator[:character] + formatted_integer[pos..]
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
if format[:ordinal]
|
|
265
|
+
suffix123 = {'1' => 'st', '2' => 'nd', '3' => 'rd'}
|
|
266
|
+
last_digit = formatted_integer[-1]
|
|
267
|
+
suffix = suffix123[last_digit]
|
|
268
|
+
suffix = 'th' if !suffix || (formatted_integer.length > 1 && formatted_integer[-2] == '1')
|
|
269
|
+
formatted_integer += suffix
|
|
270
|
+
end
|
|
271
|
+
when FORMATS[:SEQUENCE]
|
|
272
|
+
raise Jsonata::Error.new('D3130', :value => format[:token])
|
|
273
|
+
end
|
|
274
|
+
formatted_integer = "-#{formatted_integer}" if negative
|
|
275
|
+
|
|
276
|
+
formatted_integer
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# TODO what about decimal groups in the unicode supplementary planes (surrogate pairs) ???
|
|
280
|
+
DECIMAL_GROUPS = [0x30, 0x0660, 0x06F0, 0x07C0, 0x0966, 0x09E6, 0x0A66, 0x0AE6, 0x0B66, 0x0BE6, 0x0C66, 0x0CE6,
|
|
281
|
+
0x0D66, 0x0DE6, 0x0E50, 0x0ED0, 0x0F20, 0x1040, 0x1090, 0x17E0, 0x1810, 0x1946, 0x19D0, 0x1A80,
|
|
282
|
+
0x1A90, 0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0,
|
|
283
|
+
0xFF10].freeze
|
|
284
|
+
|
|
285
|
+
# preprocesses the picture string
|
|
286
|
+
# @param picture - picture string
|
|
287
|
+
# @returns analysed picture
|
|
288
|
+
def analyse_integer_picture(picture)
|
|
289
|
+
format = {
|
|
290
|
+
:type => 'integer',
|
|
291
|
+
:primary => FORMATS[:DECIMAL],
|
|
292
|
+
:case => TCASE[:LOWER],
|
|
293
|
+
:ordinal => false
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
semicolon = picture.rindex(';')
|
|
297
|
+
if semicolon.nil?
|
|
298
|
+
primary_format = picture
|
|
299
|
+
else
|
|
300
|
+
primary_format = picture[0, semicolon]
|
|
301
|
+
format_modifier = picture[(semicolon + 1)..]
|
|
302
|
+
format[:ordinal] = true if format_modifier[0] == 'o'
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
case primary_format
|
|
306
|
+
when 'A', 'a'
|
|
307
|
+
format[:case] = TCASE[:UPPER] if primary_format == 'A'
|
|
308
|
+
format[:primary] = FORMATS[:LETTERS]
|
|
309
|
+
when 'I', 'i'
|
|
310
|
+
format[:case] = TCASE[:UPPER] if primary_format == 'I'
|
|
311
|
+
format[:primary] = FORMATS[:ROMAN]
|
|
312
|
+
when 'W'
|
|
313
|
+
format[:case] = TCASE[:UPPER]
|
|
314
|
+
format[:primary] = FORMATS[:WORDS]
|
|
315
|
+
when 'Ww'
|
|
316
|
+
format[:case] = TCASE[:TITLE]
|
|
317
|
+
format[:primary] = FORMATS[:WORDS]
|
|
318
|
+
when 'w'
|
|
319
|
+
format[:primary] = FORMATS[:WORDS]
|
|
320
|
+
else
|
|
321
|
+
# this is a decimal-digit-pattern if it contains a decimal digit (from any unicode decimal digit group)
|
|
322
|
+
zero_code = nil
|
|
323
|
+
mandatory_digits = 0
|
|
324
|
+
optional_digits = 0
|
|
325
|
+
grouping_separators = []
|
|
326
|
+
separator_position = 0
|
|
327
|
+
# reverse the array to determine positions of grouping-separator-signs
|
|
328
|
+
format_codepoints = Utils.string_to_array(primary_format).map(&:ord).reverse
|
|
329
|
+
format_codepoints.each do |code_point|
|
|
330
|
+
# step though each char in the picture to determine the digit group
|
|
331
|
+
digit = false
|
|
332
|
+
DECIMAL_GROUPS.each do |group|
|
|
333
|
+
next unless code_point.between?(group, group + 9)
|
|
334
|
+
|
|
335
|
+
# codepoint is part of this decimal group
|
|
336
|
+
digit = true
|
|
337
|
+
mandatory_digits += 1
|
|
338
|
+
separator_position += 1
|
|
339
|
+
if zero_code.nil?
|
|
340
|
+
zero_code = group
|
|
341
|
+
elsif group != zero_code
|
|
342
|
+
# error! different decimal groups in the same pattern
|
|
343
|
+
raise Jsonata::Error.new('D3131')
|
|
344
|
+
end
|
|
345
|
+
break
|
|
346
|
+
end
|
|
347
|
+
next if digit
|
|
348
|
+
|
|
349
|
+
if code_point == 0x23 # # - optional-digit-sign
|
|
350
|
+
separator_position += 1
|
|
351
|
+
optional_digits += 1
|
|
352
|
+
else
|
|
353
|
+
# neither a decimal-digit-sign ot optional-digit-sign, assume it is a grouping-separator-sign
|
|
354
|
+
grouping_separators.push({
|
|
355
|
+
:position => separator_position,
|
|
356
|
+
:character => code_point.chr(Encoding::UTF_8)
|
|
357
|
+
})
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
if mandatory_digits.positive?
|
|
361
|
+
format[:primary] = FORMATS[:DECIMAL]
|
|
362
|
+
# TODO validate decimal-digit-pattern
|
|
363
|
+
|
|
364
|
+
# the decimal digit family (codepoint offset)
|
|
365
|
+
format[:zero_code] = zero_code
|
|
366
|
+
# the number of mandatory digits
|
|
367
|
+
format[:mandatory_digits] = mandatory_digits
|
|
368
|
+
# the number of optional digits
|
|
369
|
+
format[:optional_digits] = optional_digits
|
|
370
|
+
# grouping separator template
|
|
371
|
+
# are the grouping-separator-signs 'regular'?
|
|
372
|
+
regular_repeat = lambda do |separators|
|
|
373
|
+
# are the grouping positions regular? i.e. same interval between each of them
|
|
374
|
+
# is there at least one separator?
|
|
375
|
+
return 0 if separators.empty?
|
|
376
|
+
|
|
377
|
+
# are all the characters the same?
|
|
378
|
+
sep_char = separators[0][:character]
|
|
379
|
+
separators.drop(1).each do |separator|
|
|
380
|
+
return 0 if separator[:character] != sep_char
|
|
381
|
+
end
|
|
382
|
+
# are they equally spaced?
|
|
383
|
+
indexes = separators.map { |separator| separator[:position] }
|
|
384
|
+
# find the greatest common divisor of all the positions
|
|
385
|
+
factor = indexes.reduce { |a, b| a.gcd(b) }
|
|
386
|
+
# is every position separated by this divisor? If so, it's regular
|
|
387
|
+
(1..indexes.length).each do |index|
|
|
388
|
+
return 0 unless indexes.include?(index * factor)
|
|
389
|
+
end
|
|
390
|
+
factor
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
regular = regular_repeat.call(grouping_separators)
|
|
394
|
+
if regular.positive?
|
|
395
|
+
format[:regular] = true
|
|
396
|
+
format[:grouping_separators] = {
|
|
397
|
+
:position => regular,
|
|
398
|
+
:character => grouping_separators[0][:character]
|
|
399
|
+
}
|
|
400
|
+
else
|
|
401
|
+
format[:regular] = false
|
|
402
|
+
format[:grouping_separators] = grouping_separators
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
else
|
|
406
|
+
# this is a 'numbering sequence' which the spec says is implementation-defined
|
|
407
|
+
# this implementation doesn't support any numbering sequences at the moment.
|
|
408
|
+
format[:primary] = FORMATS[:SEQUENCE]
|
|
409
|
+
format[:token] = primary_format
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
format
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
DEFAULT_PRESENTATION_MODIFIERS = {
|
|
417
|
+
'Y' => '1', 'M' => '1', 'D' => '1', 'd' => '1', 'F' => 'n', 'W' => '1', 'w' => '1', 'X' => '1', 'x' => '1',
|
|
418
|
+
'H' => '1', 'h' => '1', 'P' => 'n', 'm' => '01', 's' => '01', 'f' => '1', 'Z' => '01:01', 'z' => '01:01',
|
|
419
|
+
'C' => 'n', 'E' => 'n'
|
|
420
|
+
}.freeze
|
|
421
|
+
|
|
422
|
+
# Section 9.8.4.1 the format specifier is an array of string literals and variable markers
|
|
423
|
+
# analyse the date-time picture string
|
|
424
|
+
# @param picture - picture string
|
|
425
|
+
# @returns the analysed string
|
|
426
|
+
def analyse_date_time_picture(picture)
|
|
427
|
+
spec = []
|
|
428
|
+
format = {
|
|
429
|
+
:type => 'datetime',
|
|
430
|
+
:parts => spec
|
|
431
|
+
}
|
|
432
|
+
add_literal = lambda do |start, end_pos|
|
|
433
|
+
if end_pos > start
|
|
434
|
+
literal = picture[start...end_pos]
|
|
435
|
+
# replace any doubled ]] with single ]
|
|
436
|
+
# what if there are instances of single ']' ? - the spec doesn't say
|
|
437
|
+
literal = literal.gsub(']]', ']')
|
|
438
|
+
spec.push({:type => 'literal', :value => literal})
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
start = 0
|
|
443
|
+
pos = 0
|
|
444
|
+
while pos < picture.length
|
|
445
|
+
if picture[pos] == '['
|
|
446
|
+
# check it's not a doubled [[
|
|
447
|
+
if picture[pos + 1] == '['
|
|
448
|
+
# literal [
|
|
449
|
+
add_literal.call(start, pos)
|
|
450
|
+
spec.push({:type => 'literal', :value => '['})
|
|
451
|
+
pos += 2
|
|
452
|
+
start = pos
|
|
453
|
+
next
|
|
454
|
+
end
|
|
455
|
+
# start of variable marker
|
|
456
|
+
# push the string literal (if there is one) onto the array
|
|
457
|
+
add_literal.call(start, pos)
|
|
458
|
+
start = pos
|
|
459
|
+
# search forward to closing ]
|
|
460
|
+
pos = picture.index(']', start)
|
|
461
|
+
# TODO handle error case if pos === -1
|
|
462
|
+
if pos.nil?
|
|
463
|
+
# error - no closing bracket
|
|
464
|
+
raise Jsonata::Error.new('D3135')
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
marker = picture[(start + 1)...pos]
|
|
468
|
+
# whitespace within a variable marker is ignored (i.e. remove it)
|
|
469
|
+
marker = marker.gsub(/\s+/, '')
|
|
470
|
+
defn = {
|
|
471
|
+
:type => 'marker',
|
|
472
|
+
:component => marker[0] || '' # 1. The component specifier is always present and is always a single letter.
|
|
473
|
+
}
|
|
474
|
+
comma = marker.rindex(',') # 2. The width modifier may be recognized by the presence of a comma
|
|
475
|
+
pres_mod = nil # the presentation modifiers
|
|
476
|
+
if comma.nil?
|
|
477
|
+
pres_mod = marker[1..] || ''
|
|
478
|
+
else
|
|
479
|
+
# Section 9.8.4.2 The Width Modifier
|
|
480
|
+
width_mod = marker[(comma + 1)..]
|
|
481
|
+
dash = width_mod.index('-')
|
|
482
|
+
min = nil
|
|
483
|
+
max = nil
|
|
484
|
+
parse_width = lambda do |wm|
|
|
485
|
+
if wm.nil? || wm == '*'
|
|
486
|
+
nil
|
|
487
|
+
else
|
|
488
|
+
# TODO validate wm is an unsigned int
|
|
489
|
+
Utils.parse_int(wm)
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
if dash.nil?
|
|
493
|
+
min = width_mod
|
|
494
|
+
else
|
|
495
|
+
min = width_mod[0, dash]
|
|
496
|
+
max = width_mod[(dash + 1)..]
|
|
497
|
+
end
|
|
498
|
+
width_def = {
|
|
499
|
+
:min => parse_width.call(min),
|
|
500
|
+
:max => parse_width.call(max)
|
|
501
|
+
}
|
|
502
|
+
defn[:width] = width_def
|
|
503
|
+
pres_mod = marker[1...comma] || ''
|
|
504
|
+
end
|
|
505
|
+
if pres_mod.length == 1
|
|
506
|
+
defn[:presentation1] = pres_mod # first presentation modifier
|
|
507
|
+
# TODO validate the first presentation modifier - it's either N, n, Nn or it passes analyseIntegerPicture
|
|
508
|
+
elsif pres_mod.length > 1
|
|
509
|
+
last_char = pres_mod[-1]
|
|
510
|
+
if 'atco'.include?(last_char)
|
|
511
|
+
defn[:presentation2] = last_char
|
|
512
|
+
defn[:ordinal] = true if last_char == 'o'
|
|
513
|
+
# 'c' means 'cardinal' and is the default (i.e. not 'ordinal')
|
|
514
|
+
# 'a' & 't' are ignored (not sure of their relevance to English numbering)
|
|
515
|
+
defn[:presentation1] = pres_mod[0...-1]
|
|
516
|
+
else
|
|
517
|
+
defn[:presentation1] = pres_mod
|
|
518
|
+
# TODO validate the first presentation modifier - it's either N, n, Nn or it passes analyseIntegerPicture,
|
|
519
|
+
# doesn't use ] as grouping separator, and if grouping separator is , then must have width modifier
|
|
520
|
+
end
|
|
521
|
+
else
|
|
522
|
+
# no presentation modifier specified - apply the default;
|
|
523
|
+
defn[:presentation1] = DEFAULT_PRESENTATION_MODIFIERS[defn[:component]]
|
|
524
|
+
end
|
|
525
|
+
if defn[:presentation1].nil?
|
|
526
|
+
# unknown component specifier
|
|
527
|
+
raise Jsonata::Error.new('D3132', :value => defn[:component])
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
if defn[:presentation1][0] == 'n'
|
|
531
|
+
defn[:names] = TCASE[:LOWER]
|
|
532
|
+
elsif defn[:presentation1][0] == 'N'
|
|
533
|
+
defn[:names] = if defn[:presentation1][1] == 'n'
|
|
534
|
+
TCASE[:TITLE]
|
|
535
|
+
else
|
|
536
|
+
TCASE[:UPPER]
|
|
537
|
+
end
|
|
538
|
+
elsif 'YMDdFWwXxHhmsf'.include?(defn[:component])
|
|
539
|
+
integer_pattern = defn[:presentation1]
|
|
540
|
+
integer_pattern += ";#{defn[:presentation2]}" if defn[:presentation2]
|
|
541
|
+
defn[:integer_format] = analyse_integer_picture(integer_pattern)
|
|
542
|
+
if defn[:width] && !defn[:width][:min].nil? && (defn[:integer_format][:mandatory_digits] < defn[:width][:min])
|
|
543
|
+
defn[:integer_format][:mandatory_digits] = defn[:width][:min]
|
|
544
|
+
end
|
|
545
|
+
if defn[:component] == 'Y'
|
|
546
|
+
# Section 9.8.4.4
|
|
547
|
+
defn[:n] = -1
|
|
548
|
+
if defn[:width] && !defn[:width][:max].nil?
|
|
549
|
+
defn[:n] = defn[:width][:max]
|
|
550
|
+
defn[:integer_format][:mandatory_digits] = defn[:n]
|
|
551
|
+
else
|
|
552
|
+
w = defn[:integer_format][:mandatory_digits].to_i + defn[:integer_format][:optional_digits].to_i
|
|
553
|
+
defn[:n] = w if w >= 2
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
# if the previous part is also an integer with no intervening markup, then its width for parsing must be precisely defined
|
|
557
|
+
previous_part = spec[-1]
|
|
558
|
+
if previous_part && previous_part[:integer_format]
|
|
559
|
+
previous_part[:integer_format][:parse_width] = previous_part[:integer_format][:mandatory_digits]
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
if defn[:component] == 'Z' || defn[:component] == 'z'
|
|
563
|
+
defn[:integer_format] = analyse_integer_picture(defn[:presentation1])
|
|
564
|
+
end
|
|
565
|
+
spec.push(defn)
|
|
566
|
+
start = pos + 1
|
|
567
|
+
end
|
|
568
|
+
pos += 1
|
|
569
|
+
end
|
|
570
|
+
add_literal.call(start, pos)
|
|
571
|
+
format
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
DAYS = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'].freeze
|
|
575
|
+
MONTHS = %w[January February March April May June July August September October November December].freeze
|
|
576
|
+
MILLIS_IN_A_DAY = 1000 * 60 * 60 * 24
|
|
577
|
+
|
|
578
|
+
# Equivalent of Date.UTC(year, month, day, hours, minutes, seconds, millis)
|
|
579
|
+
def self.date_utc(year, month = 0, day = 1, hours = 0, minutes = 0, seconds = 0, millis = 0)
|
|
580
|
+
values = [year, month, day, hours, minutes, seconds, millis]
|
|
581
|
+
return Float::NAN if values.any? { |v| v.nil? || (v.kind_of?(Float) && !v.finite?) }
|
|
582
|
+
|
|
583
|
+
year, month, day, hours, minutes, seconds, millis = values.map(&:truncate)
|
|
584
|
+
year += 1900 if year.between?(0, 99)
|
|
585
|
+
year += month.div(12)
|
|
586
|
+
month %= 12
|
|
587
|
+
days = days_from_civil(year, month + 1, 1) + day - 1
|
|
588
|
+
(days * MILLIS_IN_A_DAY) + (hours * 3_600_000) + (minutes * 60_000) + (seconds * 1000) + millis
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# number of days since 1970-01-01 of the given proleptic Gregorian date
|
|
592
|
+
def self.days_from_civil(year, month, day)
|
|
593
|
+
year -= 1 if month <= 2
|
|
594
|
+
era = year.div(400)
|
|
595
|
+
yoe = year - (era * 400)
|
|
596
|
+
doy = (((153 * (month + (month > 2 ? -3 : 9))) + 2) / 5) + day - 1
|
|
597
|
+
doe = (yoe * 365) + (yoe / 4) - (yoe / 100) + doy
|
|
598
|
+
(era * 146_097) + doe - 719_468
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Equivalent of new Date(millis)
|
|
602
|
+
def self.new_date(millis)
|
|
603
|
+
Time.at(Rational(millis.truncate, 1000), :in => 'UTC')
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Equivalent of date.getTime()
|
|
607
|
+
def self.get_time(date)
|
|
608
|
+
(date.to_r * 1000).floor
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Equivalent of date.getUTCDay() - 0 is Sunday
|
|
612
|
+
def self.get_utc_day(date)
|
|
613
|
+
date.wday
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def start_of_first_week(ym)
|
|
617
|
+
# ISO 8601 defines the first week of the year to be the week that contains the first Thursday
|
|
618
|
+
# XPath F&O extends this same definition for the first week of a month
|
|
619
|
+
# the week starts on a Monday - calculate the millis for the start of the first week
|
|
620
|
+
# millis for given 1st Jan of that year (at 00:00 UTC)
|
|
621
|
+
jan1 = DateTime.date_utc(ym[:year], ym[:month])
|
|
622
|
+
day_of_jan1 = DateTime.get_utc_day(DateTime.new_date(jan1))
|
|
623
|
+
day_of_jan1 = 7 if day_of_jan1.zero?
|
|
624
|
+
# if Jan 1 is Fri, Sat or Sun, then add the number of days (in millis) to jan1 to get the start of week 1
|
|
625
|
+
day_of_jan1 > 4 ? jan1 + ((8 - day_of_jan1) * MILLIS_IN_A_DAY) : jan1 - ((day_of_jan1 - 1) * MILLIS_IN_A_DAY)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def year_month(year, month)
|
|
629
|
+
{
|
|
630
|
+
:year => year,
|
|
631
|
+
:month => month,
|
|
632
|
+
:next_month => -> { month == 11 ? year_month(year + 1, 0) : year_month(year, month + 1) },
|
|
633
|
+
:previous_month => -> { month.zero? ? year_month(year - 1, 11) : year_month(year, month - 1) },
|
|
634
|
+
:next_year => -> { year_month(year + 1, month) },
|
|
635
|
+
:previous_year => -> { year_month(year - 1, month) }
|
|
636
|
+
}
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def delta_weeks(start, end_pos)
|
|
640
|
+
((end_pos - start) / (MILLIS_IN_A_DAY * 7.0)) + 1
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def get_date_time_fragment(date, component)
|
|
644
|
+
component_value = nil
|
|
645
|
+
case component
|
|
646
|
+
when 'Y' # year
|
|
647
|
+
component_value = date.year
|
|
648
|
+
when 'M' # month in year
|
|
649
|
+
component_value = date.month
|
|
650
|
+
when 'D' # day in month
|
|
651
|
+
component_value = date.day
|
|
652
|
+
when 'd' # day in year
|
|
653
|
+
# millis for given date (at 00:00 UTC)
|
|
654
|
+
today = DateTime.date_utc(date.year, date.month - 1, date.day)
|
|
655
|
+
# millis for given 1st Jan of that year (at 00:00 UTC)
|
|
656
|
+
first_jan = DateTime.date_utc(date.year, 0)
|
|
657
|
+
component_value = ((today - first_jan) / MILLIS_IN_A_DAY) + 1
|
|
658
|
+
when 'F' # day of week
|
|
659
|
+
component_value = DateTime.get_utc_day(date)
|
|
660
|
+
# ISO 8601 defines days 1-7: Mon-Sun
|
|
661
|
+
component_value = 7 if component_value.zero?
|
|
662
|
+
when 'W' # week in year
|
|
663
|
+
this_year = year_month(date.year, 0)
|
|
664
|
+
start_of_week1 = start_of_first_week(this_year)
|
|
665
|
+
today = DateTime.date_utc(this_year[:year], date.month - 1, date.day)
|
|
666
|
+
week = delta_weeks(start_of_week1, today)
|
|
667
|
+
if week > 52
|
|
668
|
+
# might be first week of the following year
|
|
669
|
+
start_of_following_year = start_of_first_week(this_year[:next_year].call)
|
|
670
|
+
week = 1 if today >= start_of_following_year
|
|
671
|
+
elsif week < 1
|
|
672
|
+
# must be end of the previous year
|
|
673
|
+
start_of_previous_year = start_of_first_week(this_year[:previous_year].call)
|
|
674
|
+
week = delta_weeks(start_of_previous_year, today)
|
|
675
|
+
end
|
|
676
|
+
component_value = week.floor
|
|
677
|
+
when 'w' # week in month
|
|
678
|
+
this_month = year_month(date.year, date.month - 1)
|
|
679
|
+
start_of_week1 = start_of_first_week(this_month)
|
|
680
|
+
today = DateTime.date_utc(this_month[:year], this_month[:month], date.day)
|
|
681
|
+
week = delta_weeks(start_of_week1, today)
|
|
682
|
+
if week > 4
|
|
683
|
+
# might be first week of the following month
|
|
684
|
+
start_of_following_month = start_of_first_week(this_month[:next_month].call)
|
|
685
|
+
week = 1 if today >= start_of_following_month
|
|
686
|
+
elsif week < 1
|
|
687
|
+
# must be end of the previous month
|
|
688
|
+
start_of_previous_month = start_of_first_week(this_month[:previous_month].call)
|
|
689
|
+
week = delta_weeks(start_of_previous_month, today)
|
|
690
|
+
end
|
|
691
|
+
component_value = week.floor
|
|
692
|
+
when 'X' # ISO week-numbering year
|
|
693
|
+
# Extension: The F&O spec says nothing about how to access the year associated with the week-of-the-year
|
|
694
|
+
# e.g. Sat 1 Jan 2005 is in the 53rd week of 2004.
|
|
695
|
+
# The 'W' component specifier gives 53, but 'Y' will give 2005.
|
|
696
|
+
# I propose to add 'X' as the component specifier to give the ISO week-numbering year (2004 in this example)
|
|
697
|
+
this_year = year_month(date.year, 0)
|
|
698
|
+
start_of_iso_year = start_of_first_week(this_year)
|
|
699
|
+
end_of_iso_year = start_of_first_week(this_year[:next_year].call)
|
|
700
|
+
now = DateTime.get_time(date)
|
|
701
|
+
component_value = if now < start_of_iso_year
|
|
702
|
+
this_year[:year] - 1
|
|
703
|
+
elsif now >= end_of_iso_year
|
|
704
|
+
this_year[:year] + 1
|
|
705
|
+
else
|
|
706
|
+
this_year[:year]
|
|
707
|
+
end
|
|
708
|
+
when 'x' # ISO week-numbering month
|
|
709
|
+
# Extension: The F&O spec says nothing about how to access the month associated with the week-of-the-month
|
|
710
|
+
# e.g. Sat 1 Jan 2005 is in the 5th week of December 2004.
|
|
711
|
+
# The 'w' component specifier gives 5, but 'W' will give January and 'Y' will give 2005.
|
|
712
|
+
# I propose to add 'x' as the component specifier to give the 'week-numbering' month (December in this example)
|
|
713
|
+
this_month = year_month(date.year, date.month - 1)
|
|
714
|
+
start_of_iso_month = start_of_first_week(this_month)
|
|
715
|
+
next_month = this_month[:next_month].call
|
|
716
|
+
end_of_iso_month = start_of_first_week(next_month)
|
|
717
|
+
now = DateTime.get_time(date)
|
|
718
|
+
component_value = if now < start_of_iso_month
|
|
719
|
+
this_month[:previous_month].call[:month] + 1
|
|
720
|
+
elsif now >= end_of_iso_month
|
|
721
|
+
next_month[:month] + 1
|
|
722
|
+
else
|
|
723
|
+
this_month[:month] + 1
|
|
724
|
+
end
|
|
725
|
+
when 'H' # hour in day (24 hours)
|
|
726
|
+
component_value = date.hour
|
|
727
|
+
when 'h' # hour in half-day (12 hours)
|
|
728
|
+
component_value = date.hour
|
|
729
|
+
component_value %= 12
|
|
730
|
+
component_value = 12 if component_value.zero?
|
|
731
|
+
when 'P' # am/pm marker
|
|
732
|
+
component_value = date.hour >= 12 ? 'pm' : 'am'
|
|
733
|
+
when 'm' # minute in hour
|
|
734
|
+
component_value = date.min
|
|
735
|
+
when 's' # second in minute
|
|
736
|
+
component_value = date.sec
|
|
737
|
+
when 'f' # fractional seconds
|
|
738
|
+
component_value = date.nsec / 1_000_000
|
|
739
|
+
when 'Z', 'z' # timezone
|
|
740
|
+
# since the date object is constructed from epoch millis, the TZ component is always be UTC.
|
|
741
|
+
when 'C' # calendar name
|
|
742
|
+
component_value = 'ISO'
|
|
743
|
+
when 'E' # era
|
|
744
|
+
component_value = 'ISO'
|
|
745
|
+
end
|
|
746
|
+
component_value
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
ISO8601_PICTURE = '[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01].[f001][Z01:01t]'
|
|
750
|
+
|
|
751
|
+
# formats the date/time as specified by the XPath fn:format-dateTime function
|
|
752
|
+
# @param millis - the timestamp to be formatted, in millis since the epoch
|
|
753
|
+
# @param picture - the picture string that specifies the format
|
|
754
|
+
# @param timezone - the timezone to use
|
|
755
|
+
# @returns the formatted timestamp
|
|
756
|
+
def format_date_time(millis, picture, timezone)
|
|
757
|
+
offset_hours = 0
|
|
758
|
+
offset_minutes = 0
|
|
759
|
+
|
|
760
|
+
unless timezone.nil?
|
|
761
|
+
# parse the hour and minute offsets
|
|
762
|
+
# assume for now the format supplied is +hhmm
|
|
763
|
+
offset = Utils.parse_int(timezone) || 0
|
|
764
|
+
offset_hours = (offset / 100.0).floor
|
|
765
|
+
offset_minutes = offset.remainder(100)
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
format_component = lambda do |date, marker_spec|
|
|
769
|
+
component_value = get_date_time_fragment(date, marker_spec[:component])
|
|
770
|
+
|
|
771
|
+
# Section 9.8.4.3 Formatting Integer-Valued Date/Time Components
|
|
772
|
+
if 'YMDdFWwXxHhms'.include?(marker_spec[:component])
|
|
773
|
+
if marker_spec[:component] == 'Y' && marker_spec[:n] != -1
|
|
774
|
+
# Section 9.8.4.4 Formatting the Year Component
|
|
775
|
+
component_value = marker_spec[:n].nil? ? Float::NAN : component_value % (10**marker_spec[:n])
|
|
776
|
+
end
|
|
777
|
+
if marker_spec[:names]
|
|
778
|
+
if marker_spec[:component] == 'M' || marker_spec[:component] == 'x'
|
|
779
|
+
component_value = MONTHS[component_value - 1]
|
|
780
|
+
elsif marker_spec[:component] == 'F'
|
|
781
|
+
component_value = DAYS[component_value]
|
|
782
|
+
else
|
|
783
|
+
raise Jsonata::Error.new('D3133', :value => marker_spec[:component])
|
|
784
|
+
end
|
|
785
|
+
if marker_spec[:names] == TCASE[:UPPER]
|
|
786
|
+
component_value = component_value.upcase
|
|
787
|
+
elsif marker_spec[:names] == TCASE[:LOWER]
|
|
788
|
+
component_value = component_value.downcase
|
|
789
|
+
end
|
|
790
|
+
if marker_spec[:width] && !marker_spec[:width][:max].nil? &&
|
|
791
|
+
component_value.length > marker_spec[:width][:max]
|
|
792
|
+
component_value = component_value[0, marker_spec[:width][:max]]
|
|
793
|
+
end
|
|
794
|
+
else
|
|
795
|
+
component_value = _format_integer(component_value, marker_spec[:integer_format])
|
|
796
|
+
end
|
|
797
|
+
elsif marker_spec[:component] == 'f'
|
|
798
|
+
# TODO Section 9.8.4.5 Formatting Fractional Seconds
|
|
799
|
+
component_value = _format_integer(component_value, marker_spec[:integer_format])
|
|
800
|
+
elsif marker_spec[:component] == 'Z' || marker_spec[:component] == 'z'
|
|
801
|
+
# Section 9.8.4.6 Formatting timezones
|
|
802
|
+
offset = (offset_hours * 100) + offset_minutes
|
|
803
|
+
if marker_spec[:integer_format][:regular]
|
|
804
|
+
component_value = _format_integer(offset, marker_spec[:integer_format])
|
|
805
|
+
else
|
|
806
|
+
num_digits = marker_spec[:integer_format][:mandatory_digits]
|
|
807
|
+
if [1, 2].include?(num_digits)
|
|
808
|
+
component_value = _format_integer(offset_hours, marker_spec[:integer_format])
|
|
809
|
+
component_value += ":#{format_integer(offset_minutes, '00')}" if offset_minutes != 0
|
|
810
|
+
elsif [3, 4].include?(num_digits)
|
|
811
|
+
component_value = _format_integer(offset, marker_spec[:integer_format])
|
|
812
|
+
else
|
|
813
|
+
raise Jsonata::Error.new('D3134', :value => num_digits)
|
|
814
|
+
end
|
|
815
|
+
end
|
|
816
|
+
component_value = "+#{component_value}" if offset >= 0
|
|
817
|
+
component_value = "GMT#{component_value}" if marker_spec[:component] == 'z'
|
|
818
|
+
component_value = 'Z' if offset.zero? && marker_spec[:presentation2] == 't'
|
|
819
|
+
elsif marker_spec[:component] == 'P'
|
|
820
|
+
# Section 9.8.4.7 Formatting Other Components
|
|
821
|
+
# Formatting P for am/pm
|
|
822
|
+
# getDateTimeFragment() always returns am/pm lower case so check for UPPER here
|
|
823
|
+
component_value = component_value.upcase if marker_spec[:names] == TCASE[:UPPER]
|
|
824
|
+
end
|
|
825
|
+
component_value
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
format_spec = if picture.nil?
|
|
829
|
+
# default to ISO 8601 format
|
|
830
|
+
analyse_date_time_picture(ISO8601_PICTURE)
|
|
831
|
+
else
|
|
832
|
+
analyse_date_time_picture(picture)
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
offset_millis = ((60 * offset_hours) + offset_minutes) * 60 * 1000
|
|
836
|
+
date_time = DateTime.new_date(millis + offset_millis)
|
|
837
|
+
|
|
838
|
+
result = +''
|
|
839
|
+
format_spec[:parts].each do |part|
|
|
840
|
+
result << if part[:type] == 'literal'
|
|
841
|
+
part[:value]
|
|
842
|
+
else
|
|
843
|
+
format_component.call(date_time, part).to_s
|
|
844
|
+
end
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
result
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
# Generate a regex to parse integers or timestamps
|
|
851
|
+
# @param format_spec - object representing the format
|
|
852
|
+
# @returns regex
|
|
853
|
+
def generate_regex(format_spec)
|
|
854
|
+
matcher = {}
|
|
855
|
+
if format_spec[:type] == 'datetime'
|
|
856
|
+
matcher[:type] = 'datetime'
|
|
857
|
+
matcher[:parts] = format_spec[:parts].map do |part|
|
|
858
|
+
res = {}
|
|
859
|
+
if part[:type] == 'literal'
|
|
860
|
+
res[:regex] = part[:value].gsub(/[.*+?^${}()|\[\]\\]/) { |m| "\\#{m}" }
|
|
861
|
+
elsif part[:component] == 'Z' || part[:component] == 'z'
|
|
862
|
+
# timezone
|
|
863
|
+
separator = nil
|
|
864
|
+
separator = part[:integer_format][:grouping_separators] unless part[:integer_format][:grouping_separators].kind_of?(Array)
|
|
865
|
+
res[:regex] = +''
|
|
866
|
+
res[:regex] << 'GMT' if part[:component] == 'z'
|
|
867
|
+
res[:regex] << '[-+][0-9]+'
|
|
868
|
+
res[:regex] << "#{separator[:character]}[0-9]+" if separator
|
|
869
|
+
res[:parse] = lambda do |value|
|
|
870
|
+
value = value[3..] if part[:component] == 'z' # remove the leading GMT
|
|
871
|
+
offset_hours = 0
|
|
872
|
+
offset_minutes = 0
|
|
873
|
+
if separator
|
|
874
|
+
offset_hours = Utils.parse_int(value[0, value.index(separator[:character])])
|
|
875
|
+
offset_minutes = Utils.parse_int(value[(value.index(separator[:character]) + 1)..])
|
|
876
|
+
else
|
|
877
|
+
# depends on number of digits
|
|
878
|
+
numdigits = value.length - 1
|
|
879
|
+
if numdigits <= 2
|
|
880
|
+
# just hour offset
|
|
881
|
+
offset_hours = Utils.parse_int(value)
|
|
882
|
+
else
|
|
883
|
+
offset_hours = Utils.parse_int(value[0, 3])
|
|
884
|
+
offset_minutes = Utils.parse_int(value[3..])
|
|
885
|
+
end
|
|
886
|
+
end
|
|
887
|
+
(offset_hours * 60) + offset_minutes
|
|
888
|
+
end
|
|
889
|
+
elsif part[:component] == 'f'
|
|
890
|
+
res[:regex] = '[0-9]+'
|
|
891
|
+
res[:parse] = lambda do |value|
|
|
892
|
+
Float("0.#{value[0, 3]}") * 1000
|
|
893
|
+
end
|
|
894
|
+
elsif part[:integer_format]
|
|
895
|
+
res = generate_regex(part[:integer_format])
|
|
896
|
+
else
|
|
897
|
+
# must be a month or day name
|
|
898
|
+
res[:regex] = '[a-zA-Z]+'
|
|
899
|
+
lookup = {}
|
|
900
|
+
if part[:component] == 'M' || part[:component] == 'x'
|
|
901
|
+
# months
|
|
902
|
+
MONTHS.each_with_index do |name, index|
|
|
903
|
+
if part[:width] && part[:width][:max]
|
|
904
|
+
lookup[name[0, part[:width][:max]]] = index + 1
|
|
905
|
+
else
|
|
906
|
+
lookup[name] = index + 1
|
|
907
|
+
end
|
|
908
|
+
end
|
|
909
|
+
elsif part[:component] == 'F'
|
|
910
|
+
# days
|
|
911
|
+
DAYS.each_with_index do |name, index|
|
|
912
|
+
next unless index.positive?
|
|
913
|
+
|
|
914
|
+
if part[:width] && part[:width][:max]
|
|
915
|
+
lookup[name[0, part[:width][:max]]] = index
|
|
916
|
+
else
|
|
917
|
+
lookup[name] = index
|
|
918
|
+
end
|
|
919
|
+
end
|
|
920
|
+
elsif part[:component] == 'P'
|
|
921
|
+
lookup = {'am' => 0, 'AM' => 0, 'pm' => 1, 'PM' => 1}
|
|
922
|
+
else
|
|
923
|
+
# unsupported 'name' option for this component
|
|
924
|
+
raise Jsonata::Error.new('D3133', :value => part[:component])
|
|
925
|
+
end
|
|
926
|
+
res[:parse] = ->(value) { lookup[value] }
|
|
927
|
+
end
|
|
928
|
+
res[:component] = part[:component]
|
|
929
|
+
res
|
|
930
|
+
end
|
|
931
|
+
else # type === 'integer'
|
|
932
|
+
matcher[:type] = 'integer'
|
|
933
|
+
is_upper = format_spec[:case] == TCASE[:UPPER]
|
|
934
|
+
|
|
935
|
+
case format_spec[:primary]
|
|
936
|
+
when FORMATS[:LETTERS]
|
|
937
|
+
matcher[:regex] = is_upper ? '[A-Z]+' : '[a-z]+'
|
|
938
|
+
matcher[:parse] = lambda do |value|
|
|
939
|
+
letters_to_decimal(value, is_upper ? 'A' : 'a')
|
|
940
|
+
end
|
|
941
|
+
when FORMATS[:ROMAN]
|
|
942
|
+
matcher[:regex] = is_upper ? '[MDCLXVI]+' : '[mdclxvi]+'
|
|
943
|
+
matcher[:parse] = lambda do |value|
|
|
944
|
+
roman_to_decimal(is_upper ? value : value.upcase)
|
|
945
|
+
end
|
|
946
|
+
when FORMATS[:WORDS]
|
|
947
|
+
matcher[:regex] = "(?:#{(WORD_VALUES.keys + ['and', '[\\-, ]']).join('|')})+"
|
|
948
|
+
matcher[:parse] = lambda do |value|
|
|
949
|
+
words_to_number(value.downcase)
|
|
950
|
+
end
|
|
951
|
+
when FORMATS[:DECIMAL]
|
|
952
|
+
matcher[:regex] = +'[0-9]'
|
|
953
|
+
matcher[:regex] << if format_spec[:parse_width]
|
|
954
|
+
"{#{format_spec[:parse_width]}}"
|
|
955
|
+
else
|
|
956
|
+
'+'
|
|
957
|
+
end
|
|
958
|
+
if format_spec[:ordinal]
|
|
959
|
+
# ordinals
|
|
960
|
+
matcher[:regex] << '(?:th|st|nd|rd)'
|
|
961
|
+
end
|
|
962
|
+
matcher[:parse] = lambda do |value|
|
|
963
|
+
digits = value
|
|
964
|
+
if format_spec[:ordinal]
|
|
965
|
+
# strip off the suffix
|
|
966
|
+
digits = value[0...-2]
|
|
967
|
+
end
|
|
968
|
+
# strip out the separators
|
|
969
|
+
if format_spec[:regular]
|
|
970
|
+
digits = digits.split(',', -1).join
|
|
971
|
+
else
|
|
972
|
+
format_spec[:grouping_separators].each do |sep|
|
|
973
|
+
digits = digits.split(sep[:character], -1).join
|
|
974
|
+
end
|
|
975
|
+
end
|
|
976
|
+
if format_spec[:zero_code] != 0x30
|
|
977
|
+
# apply offset
|
|
978
|
+
digits = digits.each_char.map { |char| (char.ord - format_spec[:zero_code] + 0x30).chr(Encoding::UTF_8) }.join
|
|
979
|
+
end
|
|
980
|
+
Utils.parse_int(digits)
|
|
981
|
+
end
|
|
982
|
+
when FORMATS[:SEQUENCE]
|
|
983
|
+
raise Jsonata::Error.new('D3130', :value => format_spec[:token])
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
end
|
|
987
|
+
matcher
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
# parse a string containing an integer as specified by the picture string
|
|
991
|
+
# @param value - the string to parse
|
|
992
|
+
# @param picture - the picture string
|
|
993
|
+
# @returns the parsed number
|
|
994
|
+
def parse_integer(value, picture)
|
|
995
|
+
return nil if value.nil?
|
|
996
|
+
|
|
997
|
+
format_spec = analyse_integer_picture(picture)
|
|
998
|
+
match_spec = generate_regex(format_spec)
|
|
999
|
+
# const fullRegex = '^' + matchSpec.regex + '$';
|
|
1000
|
+
# const matcher = new RegExp(fullRegex);
|
|
1001
|
+
# TODO validate input based on the matcher regex
|
|
1002
|
+
match_spec[:parse].call(value)
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
# parse a string containing a timestamp as specified by the picture string
|
|
1006
|
+
# @param timestamp - the string to parse
|
|
1007
|
+
# @param picture - the picture string
|
|
1008
|
+
# @returns the parsed timestamp in millis since the epoch
|
|
1009
|
+
def parse_date_time(timestamp, picture)
|
|
1010
|
+
format_spec = analyse_date_time_picture(picture)
|
|
1011
|
+
match_spec = generate_regex(format_spec)
|
|
1012
|
+
full_regex = "\\A#{match_spec[:parts].map { |part| "(#{part[:regex]})" }.join}\\z"
|
|
1013
|
+
|
|
1014
|
+
matcher = Regexp.new(full_regex, Regexp::IGNORECASE) # TODO can cache this against the picture
|
|
1015
|
+
info = matcher.match(timestamp)
|
|
1016
|
+
return nil if info.nil?
|
|
1017
|
+
|
|
1018
|
+
# validate what we've just parsed - do we have enough information to create a timestamp?
|
|
1019
|
+
# rules:
|
|
1020
|
+
# The date is specified by one of:
|
|
1021
|
+
# {Y, M, D} (dateA)
|
|
1022
|
+
# or {Y, d} (dateB)
|
|
1023
|
+
# or {Y, x, w, F} (dateC)
|
|
1024
|
+
# or {X, W, F} (dateD)
|
|
1025
|
+
# The time is specified by one of:
|
|
1026
|
+
# {H, m, s, f} (timeA)
|
|
1027
|
+
# or {P, h, m, s, f} (timeB)
|
|
1028
|
+
# All sets can have an optional Z
|
|
1029
|
+
# To create a timestamp (epoch millis) we need both date and time, but we can default missing
|
|
1030
|
+
# information according to the following rules:
|
|
1031
|
+
# - line up one combination of the above from date, and one from time, most significant value (MSV) to least significant (LSV
|
|
1032
|
+
# - for the values that have been captured, if there are any gaps between MSV and LSV, then throw an error
|
|
1033
|
+
# (e.g.) if hour and seconds, but not minutes is given - throw
|
|
1034
|
+
# (e.g.) if month, hour and minutes, but not day-of-month is given - throw
|
|
1035
|
+
# - anything right of the LSV should be defaulted to zero
|
|
1036
|
+
# (e.g.) if hour and minutes given, default seconds and fractional seconds to zero
|
|
1037
|
+
# (e.g.) if date only given, default the time to 0:00:00.000 (midnight)
|
|
1038
|
+
# - anything left of the MSV should be defaulted to the value of that component returned by $now()
|
|
1039
|
+
# (e.g.) if time only given, default the date to today
|
|
1040
|
+
# (e.g.) if month and date given, default to this year (and midnight, by previous rule)
|
|
1041
|
+
# -- default values for X, x, W, w, F will be derived from the values returned by $now()
|
|
1042
|
+
|
|
1043
|
+
# implement the above rules
|
|
1044
|
+
# determine which of the above date/time combinations we have by using bit masks
|
|
1045
|
+
|
|
1046
|
+
# Y X M x W w d D F P H h m s f Z
|
|
1047
|
+
# dateA 1 0 1 0 0 0 0 1 ? 0 - must not appear
|
|
1048
|
+
# dateB 1 0 0 0 0 0 1 0 ? 1 - can appear - relevant
|
|
1049
|
+
# dateC 0 1 0 1 0 1 0 0 1 ? - can appear - ignored
|
|
1050
|
+
# dateD 0 1 0 0 1 0 0 0 1
|
|
1051
|
+
# timeA 0 1 0 1 1 1
|
|
1052
|
+
# timeB 1 0 1 1 1 1
|
|
1053
|
+
|
|
1054
|
+
# create bitmasks based on the above
|
|
1055
|
+
# date mask YXMxWwdD
|
|
1056
|
+
dm_a = 161 # binary 10100001
|
|
1057
|
+
dm_b = 130 # binary 10000010
|
|
1058
|
+
dm_c = 84 # binary 01010100
|
|
1059
|
+
dm_d = 72 # binary 01001000
|
|
1060
|
+
# time mask PHhmsf
|
|
1061
|
+
tm_a = 23 # binary 010111
|
|
1062
|
+
tm_b = 47 # binary 101111
|
|
1063
|
+
|
|
1064
|
+
components = {}
|
|
1065
|
+
(1...info.size).each do |i|
|
|
1066
|
+
mpart = match_spec[:parts][i - 1]
|
|
1067
|
+
components[mpart[:component]] = mpart[:parse].call(info[i]) if mpart[:parse]
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
# nothing specified
|
|
1071
|
+
return nil if components.empty?
|
|
1072
|
+
|
|
1073
|
+
mask = 0
|
|
1074
|
+
|
|
1075
|
+
shift = lambda do |bit|
|
|
1076
|
+
mask <<= 1
|
|
1077
|
+
mask += Utils.truthy?(bit) ? 1 : 0
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
is_type = lambda do |type|
|
|
1081
|
+
# shouldn't match any 0's, must match at least one 1
|
|
1082
|
+
type.allbits?(mask) && type.anybits?(mask)
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
'YXMxWwdD'.each_char { |part| shift.call(components[part]) }
|
|
1086
|
+
|
|
1087
|
+
date_a = is_type.call(dm_a)
|
|
1088
|
+
date_b = !date_a && is_type.call(dm_b)
|
|
1089
|
+
date_c = is_type.call(dm_c)
|
|
1090
|
+
date_d = !date_c && is_type.call(dm_d)
|
|
1091
|
+
|
|
1092
|
+
mask = 0
|
|
1093
|
+
'PHhmsf'.each_char { |part| shift.call(components[part]) }
|
|
1094
|
+
|
|
1095
|
+
time_a = is_type.call(tm_a)
|
|
1096
|
+
time_b = !time_a && is_type.call(tm_b)
|
|
1097
|
+
|
|
1098
|
+
# should only be zero or one date type and zero or one time type
|
|
1099
|
+
|
|
1100
|
+
date_comps = if date_b
|
|
1101
|
+
'YD'
|
|
1102
|
+
elsif date_c
|
|
1103
|
+
'XxwF'
|
|
1104
|
+
elsif date_d
|
|
1105
|
+
'XWF'
|
|
1106
|
+
else
|
|
1107
|
+
'YMD'
|
|
1108
|
+
end
|
|
1109
|
+
time_comps = time_b ? 'Phmsf' : 'Hmsf'
|
|
1110
|
+
|
|
1111
|
+
comps = date_comps + time_comps
|
|
1112
|
+
|
|
1113
|
+
# step through the candidate parts from most significant to least significant
|
|
1114
|
+
# default the most significant unspecified parts to current timestamp component
|
|
1115
|
+
# default the least significant unspecified parts to zero
|
|
1116
|
+
# if any gaps in between the specified parts, throw an error
|
|
1117
|
+
|
|
1118
|
+
now = environment.timestamp # must get the fixed timestamp from jsonata
|
|
1119
|
+
|
|
1120
|
+
start_specified = false
|
|
1121
|
+
end_specified = false
|
|
1122
|
+
comps.each_char do |part|
|
|
1123
|
+
if components[part].nil?
|
|
1124
|
+
if start_specified
|
|
1125
|
+
# past the specified block - default to zero
|
|
1126
|
+
components[part] = 'MDd'.include?(part) ? 1 : 0
|
|
1127
|
+
end_specified = true
|
|
1128
|
+
else
|
|
1129
|
+
# haven't hit the specified block yet, default to current timestamp
|
|
1130
|
+
components[part] = get_date_time_fragment(now, part)
|
|
1131
|
+
end
|
|
1132
|
+
else
|
|
1133
|
+
start_specified = true
|
|
1134
|
+
raise Jsonata::Error.new('D3136') if end_specified
|
|
1135
|
+
end
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
# validate and fill in components
|
|
1139
|
+
if !components['M'].nil? && components['M'].positive?
|
|
1140
|
+
components['M'] -= 1 # Date.UTC requires a zero-indexed month
|
|
1141
|
+
else
|
|
1142
|
+
components['M'] = 0 # default to January
|
|
1143
|
+
end
|
|
1144
|
+
if date_b
|
|
1145
|
+
# millis for given 1st Jan of that year (at 00:00 UTC)
|
|
1146
|
+
first_jan = DateTime.date_utc(components['Y'], 0)
|
|
1147
|
+
offset_millis = (components['d'] - 1) * 1000 * 60 * 60 * 24
|
|
1148
|
+
derived_date = DateTime.new_date(first_jan + offset_millis)
|
|
1149
|
+
components['M'] = derived_date.month - 1
|
|
1150
|
+
components['D'] = derived_date.day
|
|
1151
|
+
end
|
|
1152
|
+
if date_c
|
|
1153
|
+
# TODO implement this
|
|
1154
|
+
# parsing this format not currently supported
|
|
1155
|
+
raise Jsonata::Error.new('D3136')
|
|
1156
|
+
end
|
|
1157
|
+
if date_d
|
|
1158
|
+
# TODO implement this
|
|
1159
|
+
# parsing this format (ISO week date) not currently supported
|
|
1160
|
+
raise Jsonata::Error.new('D3136')
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
if time_b
|
|
1164
|
+
# 12hr to 24hr
|
|
1165
|
+
components['H'] = components['h'] == 12 ? 0 : components['h']
|
|
1166
|
+
components['H'] += 12 if components['P'] == 1
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
millis = DateTime.date_utc(components['Y'], components['M'], components['D'], components['H'], components['m'],
|
|
1170
|
+
components['s'], components['f'])
|
|
1171
|
+
if Utils.truthy?(components['Z']) || Utils.truthy?(components['z'])
|
|
1172
|
+
# adjust for timezone
|
|
1173
|
+
millis -= (Utils.truthy?(components['Z']) ? components['Z'] : components['z']) * 60 * 1000
|
|
1174
|
+
end
|
|
1175
|
+
millis
|
|
1176
|
+
end
|
|
1177
|
+
|
|
1178
|
+
# Regular expression to match an ISO 8601 formatted timestamp
|
|
1179
|
+
ISO8601_REGEX = /\A\d{4}(-[01]\d)?(-[0-3]\d)?(T[0-2]\d:[0-5]\d:[0-5]\d)?(\.\d+)?([+-][0-2]\d:?[0-5]\d|Z)?\z/
|
|
1180
|
+
|
|
1181
|
+
# Equivalent of Date.parse for the ISO 8601 subset accepted by ISO8601_REGEX
|
|
1182
|
+
def self.date_parse(timestamp)
|
|
1183
|
+
m = /\A(\d{4})(?:-(\d\d))?(?:-(\d\d))?(?:T(\d\d):(\d\d):(\d\d))?(?:\.(\d+))?(Z|([+-])(\d\d):?(\d\d))?\z/.match(timestamp)
|
|
1184
|
+
return Float::NAN if m.nil?
|
|
1185
|
+
|
|
1186
|
+
year = m[1].to_i
|
|
1187
|
+
month = (m[2] || '01').to_i
|
|
1188
|
+
day = (m[3] || '01').to_i
|
|
1189
|
+
hours = m[4].to_i
|
|
1190
|
+
minutes = m[5].to_i
|
|
1191
|
+
seconds = m[6].to_i
|
|
1192
|
+
millis = m[7] ? m[7][0, 3].ljust(3, '0').to_i : 0
|
|
1193
|
+
if month < 1 || month > 12 || day < 1 || day > 31 || hours > 24 || (hours == 24 && (minutes + seconds + millis).positive?)
|
|
1194
|
+
return Float::NAN
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1197
|
+
# time values are relative to UTC unless an offset is specified
|
|
1198
|
+
result = (days_from_civil(year, month, day) * MILLIS_IN_A_DAY) + (hours * 3_600_000) + (minutes * 60_000) +
|
|
1199
|
+
(seconds * 1000) + millis
|
|
1200
|
+
if m[9]
|
|
1201
|
+
offset = (m[10].to_i * 60) + m[11].to_i
|
|
1202
|
+
result -= (m[9] == '-' ? -offset : offset) * 60_000
|
|
1203
|
+
end
|
|
1204
|
+
result
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
# Converts an ISO 8601 timestamp to milliseconds since the epoch
|
|
1208
|
+
#
|
|
1209
|
+
# @param timestamp - the timestamp to be converted
|
|
1210
|
+
# @param picture - the picture string defining the format of the timestamp (defaults to ISO 8601)
|
|
1211
|
+
# @returns milliseconds since the epoch
|
|
1212
|
+
def to_millis(timestamp, picture)
|
|
1213
|
+
# undefined inputs always return undefined
|
|
1214
|
+
return nil if timestamp.nil?
|
|
1215
|
+
|
|
1216
|
+
if picture.nil?
|
|
1217
|
+
raise Jsonata::Error.new('D3110', :value => timestamp) unless ISO8601_REGEX.match?(timestamp)
|
|
1218
|
+
|
|
1219
|
+
DateTime.date_parse(timestamp)
|
|
1220
|
+
else
|
|
1221
|
+
parse_date_time(timestamp, picture)
|
|
1222
|
+
end
|
|
1223
|
+
end
|
|
1224
|
+
|
|
1225
|
+
# Converts milliseconds since the epoch to an ISO 8601 timestamp
|
|
1226
|
+
# @param millis - milliseconds since the epoch to be converted
|
|
1227
|
+
# @param picture - the picture string defining the format of the timestamp (defaults to ISO 8601)
|
|
1228
|
+
# @param timezone - the timezone to format the timestamp in (defaults to UTC)
|
|
1229
|
+
# @returns the formatted timestamp
|
|
1230
|
+
def from_millis(millis, picture, timezone)
|
|
1231
|
+
# undefined inputs always return undefined
|
|
1232
|
+
return nil if millis.nil?
|
|
1233
|
+
|
|
1234
|
+
format_date_time(millis, picture, timezone)
|
|
1235
|
+
end
|
|
1236
|
+
end
|
|
1237
|
+
end
|