quack-ruby 0.1.0-aarch64-linux-gnu
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/LICENSE +21 -0
- data/LICENSE-DuckDB +7 -0
- data/LICENSE-QuackProtocol +21 -0
- data/LICENSE-Rust-dependencies.json +2057 -0
- data/README.md +229 -0
- data/lib/quack/3.2/quack_native.so +0 -0
- data/lib/quack/3.3/quack_native.so +0 -0
- data/lib/quack/3.4/quack_native.so +0 -0
- data/lib/quack/codec.rb +263 -0
- data/lib/quack/connection.rb +131 -0
- data/lib/quack/logical_types.rb +72 -0
- data/lib/quack/result.rb +64 -0
- data/lib/quack/sql.rb +183 -0
- data/lib/quack/types.rb +40 -0
- data/lib/quack/variant.rb +298 -0
- data/lib/quack.rb +34 -0
- metadata +92 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module Quack
|
|
2
|
+
# Preserve the SQL timestamp kind as well as Ruby's nanosecond-precision instant.
|
|
3
|
+
# Time equality/arithmetic continue to use Ruby's standard behavior.
|
|
4
|
+
class Timestamp < Time
|
|
5
|
+
attr_reader :duckdb_type
|
|
6
|
+
|
|
7
|
+
def self.from(value, type)
|
|
8
|
+
seconds = value.is_a?(DateTime) ? (value.new_offset(0) - DateTime.new(1970, 1, 1)) * 86_400 : value.to_r
|
|
9
|
+
at(seconds).utc.tap { |time| time.instance_variable_set(:@duckdb_type, String(type).freeze) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def getutc
|
|
13
|
+
super.tap { |time| time.instance_variable_set(:@duckdb_type, duckdb_type) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def getlocal(*args)
|
|
17
|
+
super.tap { |time| time.instance_variable_set(:@duckdb_type, duckdb_type) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def +(seconds) = self.class.from(super, duckdb_type)
|
|
21
|
+
def -(other)
|
|
22
|
+
result = super
|
|
23
|
+
result.is_a?(Time) ? self.class.from(result, duckdb_type) : result
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
BitString = Data.define(:bits) do
|
|
28
|
+
def initialize(bits:)
|
|
29
|
+
bits = String(bits)
|
|
30
|
+
raise ArgumentError, "BIT values need at least one 0 or 1 bit" unless bits.match?(/\A[01]+\z/)
|
|
31
|
+
super(bits: bits.dup.freeze)
|
|
32
|
+
end
|
|
33
|
+
def to_s = bits
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Standard WKB is a lossless geometry value, including empty/Z/M geometries.
|
|
37
|
+
Geometry = Data.define(:wkb, :crs) do
|
|
38
|
+
def initialize(wkb:, crs: nil)
|
|
39
|
+
super(wkb: String(wkb).b.freeze, crs: crs&.dup&.freeze)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Map = Data.define(:entries, :key_type, :value_type) do
|
|
44
|
+
include Enumerable
|
|
45
|
+
def initialize(entries:, key_type: nil, value_type: nil)
|
|
46
|
+
super(entries: entries.to_h.freeze, key_type: key_type, value_type: value_type)
|
|
47
|
+
end
|
|
48
|
+
def each(&block) = entries.each(&block)
|
|
49
|
+
def [](key) = entries[key]
|
|
50
|
+
def to_h = entries.dup
|
|
51
|
+
def ==(other) = other.is_a?(Map) && entries == other.entries
|
|
52
|
+
alias eql? ==
|
|
53
|
+
def hash = [self.class, entries].hash
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Union = Data.define(:tag, :value, :members) do
|
|
57
|
+
def initialize(tag:, value:, members: nil)
|
|
58
|
+
super(tag: String(tag).freeze, value: value, members: members)
|
|
59
|
+
end
|
|
60
|
+
def ==(other) = other.is_a?(Union) && tag == other.tag && value == other.value
|
|
61
|
+
alias eql? ==
|
|
62
|
+
def hash = [self.class, tag, value].hash
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
Tuple = Data.define(:values) do
|
|
66
|
+
def initialize(values:)
|
|
67
|
+
super(values: values.to_a.freeze)
|
|
68
|
+
end
|
|
69
|
+
def [](index) = values[index]
|
|
70
|
+
def to_a = values.dup
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/quack/result.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Quack
|
|
2
|
+
class Result
|
|
3
|
+
include Enumerable
|
|
4
|
+
attr_reader :columns, :rows, :column_metadata
|
|
5
|
+
|
|
6
|
+
def initialize(metadata, rows)
|
|
7
|
+
@column_metadata = metadata.freeze
|
|
8
|
+
@columns = metadata.map { |col| col.fetch("name") }.freeze
|
|
9
|
+
@rows = rows
|
|
10
|
+
end
|
|
11
|
+
def each
|
|
12
|
+
return enum_for(__method__) unless block_given?
|
|
13
|
+
rows.each { |row| yield columns.zip(row).to_h }
|
|
14
|
+
self
|
|
15
|
+
end
|
|
16
|
+
def affected_rows
|
|
17
|
+
columns == ["Count"] && rows.first&.first.is_a?(Integer) ? rows.first.first : 0
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Stream
|
|
22
|
+
include Enumerable
|
|
23
|
+
attr_reader :column_metadata, :columns
|
|
24
|
+
|
|
25
|
+
def initialize(native)
|
|
26
|
+
@native = native
|
|
27
|
+
@column_metadata = native.columns.freeze
|
|
28
|
+
@columns = column_metadata.map { |col| col.fetch("name") }.freeze
|
|
29
|
+
@closed = false
|
|
30
|
+
end
|
|
31
|
+
def each_batch
|
|
32
|
+
return enum_for(__method__) unless block_given?
|
|
33
|
+
begin
|
|
34
|
+
while (batch = @native.next_batch)
|
|
35
|
+
batch.each do |row|
|
|
36
|
+
row.each_index { |index| row[index] = Codec.decode(row[index], column_metadata[index].fetch("duckdb_type")) }
|
|
37
|
+
end
|
|
38
|
+
yield batch
|
|
39
|
+
end
|
|
40
|
+
ensure
|
|
41
|
+
close
|
|
42
|
+
end
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
def each_row
|
|
46
|
+
return enum_for(__method__) unless block_given?
|
|
47
|
+
each_batch { |batch| batch.each { |row| yield row } }
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
def each
|
|
51
|
+
return enum_for(__method__) unless block_given?
|
|
52
|
+
each_row { |row| yield columns.zip(row).to_h }
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
def close
|
|
56
|
+
return if @closed
|
|
57
|
+
@native.close
|
|
58
|
+
@closed = true
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
def closed? = @closed
|
|
62
|
+
def to_result = Result.new(column_metadata, each_row.to_a)
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/quack/sql.rb
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
require "strscan"
|
|
2
|
+
|
|
3
|
+
module Quack
|
|
4
|
+
module SQL
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def identifier(name)
|
|
8
|
+
'"' + String(name).gsub('"', '""') + '"'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def identifier_parts(name)
|
|
12
|
+
scanner = StringScanner.new(String(name))
|
|
13
|
+
parts = []
|
|
14
|
+
until scanner.eos?
|
|
15
|
+
if scanner.scan(/"/)
|
|
16
|
+
part = +""
|
|
17
|
+
closed = false
|
|
18
|
+
until scanner.eos?
|
|
19
|
+
char = scanner.getch
|
|
20
|
+
if char == '"'
|
|
21
|
+
if scanner.scan(/"/) then part << '"'
|
|
22
|
+
else closed = true; break
|
|
23
|
+
end
|
|
24
|
+
else part << char
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
raise ArgumentError, "unterminated quoted identifier" unless closed
|
|
28
|
+
else
|
|
29
|
+
part = scanner.scan(/[^."]+/)
|
|
30
|
+
end
|
|
31
|
+
raise ArgumentError, "invalid qualified identifier" if part.nil? || part.empty?
|
|
32
|
+
parts << part
|
|
33
|
+
break if scanner.eos?
|
|
34
|
+
raise ArgumentError, "invalid qualified identifier" unless scanner.scan(/\./) && !scanner.eos?
|
|
35
|
+
end
|
|
36
|
+
raise ArgumentError, "expected one to three identifier parts" unless (1..3).cover?(parts.length)
|
|
37
|
+
parts
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def literal(value)
|
|
41
|
+
case value
|
|
42
|
+
when nil then "NULL"
|
|
43
|
+
when true then "TRUE"
|
|
44
|
+
when false then "FALSE"
|
|
45
|
+
when Integer
|
|
46
|
+
return "'#{value}'::BIGNUM" unless (-(2**127)...2**128).cover?(value)
|
|
47
|
+
value.negative? ? "(#{value})" : value.to_s
|
|
48
|
+
when BigDecimal
|
|
49
|
+
raise ArgumentError, "non-finite decimal parameter" unless value.finite?
|
|
50
|
+
text = value.to_s("F").sub(/\.0\z/, "")
|
|
51
|
+
integer, fraction = text.delete_prefix("-").split(".")
|
|
52
|
+
scale = fraction.to_s.length
|
|
53
|
+
width = [integer.sub(/\A0+/, "").length + scale, 1].max
|
|
54
|
+
raise RangeError, "DuckDB decimals support at most 38 digits" if width > 38
|
|
55
|
+
"'#{text}'::DECIMAL(#{width},#{scale})"
|
|
56
|
+
when Float
|
|
57
|
+
return "'NaN'::DOUBLE" if value.nan?
|
|
58
|
+
return value.positive? ? "'Infinity'::DOUBLE" : "'-Infinity'::DOUBLE" if value.infinite?
|
|
59
|
+
"'#{value}'::DOUBLE"
|
|
60
|
+
when String
|
|
61
|
+
if value.encoding == Encoding::BINARY
|
|
62
|
+
"from_hex('#{value.unpack1('H*')}')"
|
|
63
|
+
else
|
|
64
|
+
text = value.encode(Encoding::UTF_8)
|
|
65
|
+
text.include?("\0") ? "decode(from_hex('#{text.unpack1('H*')}'))" : "'#{text.gsub("'", "''")}'"
|
|
66
|
+
end
|
|
67
|
+
when Timestamp then timestamp_literal(value, value.duckdb_type)
|
|
68
|
+
when Time, DateTime
|
|
69
|
+
fraction = value.is_a?(DateTime) ? value.sec_fraction : value.to_r % 1
|
|
70
|
+
timestamp_literal(value, ((fraction * 1_000_000) % 1).zero? ? "TIMESTAMPTZ" : "TIMESTAMP_NS")
|
|
71
|
+
when Date then "DATE '#{value.iso8601}'"
|
|
72
|
+
when TimeOfDay
|
|
73
|
+
type = if value.utc_offset
|
|
74
|
+
"TIMETZ"
|
|
75
|
+
elsif (value.nanoseconds % 1000).zero?
|
|
76
|
+
"TIME"
|
|
77
|
+
else
|
|
78
|
+
"TIME_NS"
|
|
79
|
+
end
|
|
80
|
+
"#{type} '#{value}'"
|
|
81
|
+
when Interval then "INTERVAL '#{Integer(value.months)} months #{Integer(value.days)} days #{Integer(value.microseconds)} microseconds'"
|
|
82
|
+
when InfiniteTemporal
|
|
83
|
+
type = (%w[DATE] + TIMESTAMP_TYPES).include?(value.type) ? value.type : raise(ArgumentError, "invalid temporal type")
|
|
84
|
+
"#{type} '#{value.sign.negative? ? '-' : ''}infinity'"
|
|
85
|
+
when BitString then "#{literal(value.bits)}::BIT"
|
|
86
|
+
when Geometry
|
|
87
|
+
geometry = "ST_GeomFromWKB(#{literal(value.wkb)})"
|
|
88
|
+
value.crs ? "#{geometry}::GEOMETRY(#{literal(value.crs)})" : geometry
|
|
89
|
+
when Map
|
|
90
|
+
expression = "map([#{value.entries.keys.map { |key| literal(key) }.join(', ')}], [#{value.entries.values.map { |item| literal(item) }.join(', ')}])"
|
|
91
|
+
if value.key_type && value.value_type
|
|
92
|
+
seed = "map([#{Codec.type_expression(value.key_type)}][0:0], [#{Codec.type_expression(value.value_type)}][0:0])"
|
|
93
|
+
"cast_to_type(#{expression}, #{seed})"
|
|
94
|
+
else expression
|
|
95
|
+
end
|
|
96
|
+
when Union
|
|
97
|
+
active = literal(value.value)
|
|
98
|
+
active = "(#{active})::#{Codec.sql_type(value.members.fetch(value.tag))}" if value.members
|
|
99
|
+
expression = "union_value(#{identifier(value.tag)} := #{active})"
|
|
100
|
+
if value.members
|
|
101
|
+
"#{expression}::UNION(#{value.members.map { |name, type| "#{identifier(name)} #{Codec.sql_type(type)}" }.join(', ')})"
|
|
102
|
+
else expression
|
|
103
|
+
end
|
|
104
|
+
when Tuple then "row(#{value.values.map { |item| literal(item) }.join(', ')})"
|
|
105
|
+
when Variant then value.to_sql
|
|
106
|
+
when Array then "[#{value.map { |v| literal(v) }.join(', ')}]"
|
|
107
|
+
when Hash then "{#{value.map { |key, v| "#{literal(String(key))}: #{literal(v)}" }.join(', ')}}"
|
|
108
|
+
else raise TypeError, "unsupported Quack parameter type: #{value.class}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
TIMESTAMP_TYPES = %w[TIMESTAMP_S TIMESTAMP_MS TIMESTAMP TIMESTAMP_NS TIMESTAMPTZ TIMESTAMPTZ_NS].freeze
|
|
113
|
+
|
|
114
|
+
def timestamp_literal(value, type)
|
|
115
|
+
raise ArgumentError, "invalid timestamp type" unless TIMESTAMP_TYPES.include?(type)
|
|
116
|
+
return "#{type} '#{value.sign.negative? ? '-' : ''}infinity'" if value.is_a?(InfiniteTemporal)
|
|
117
|
+
Codec.pack(value, {"id" => Codec::SQL_NAMES.key(type) || "Timestamp"})
|
|
118
|
+
utc = value.is_a?(DateTime) ? value.new_offset(0).new_start(Date::GREGORIAN) : value.getutc
|
|
119
|
+
suffix = type.start_with?("TIMESTAMPTZ") ? "+00" : ""
|
|
120
|
+
"#{type} '#{utc.strftime('%Y-%m-%d %H:%M:%S.%N')}#{suffix}'"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Parameter substitution is client-side. The lexer preserves UTF-8, quoted
|
|
124
|
+
# identifiers/strings, dollar strings, and nested comments verbatim.
|
|
125
|
+
def bind(sql, parameters = nil)
|
|
126
|
+
return String(sql) if parameters.nil?
|
|
127
|
+
unless parameters.is_a?(Array) || parameters.is_a?(Hash)
|
|
128
|
+
raise ArgumentError, "parameters must be an Array or Hash"
|
|
129
|
+
end
|
|
130
|
+
named = parameters.is_a?(Hash)
|
|
131
|
+
parameters = parameters.transform_keys(&:to_s) if named
|
|
132
|
+
scanner = StringScanner.new(String(sql))
|
|
133
|
+
output = +""
|
|
134
|
+
index = 0
|
|
135
|
+
until scanner.eos?
|
|
136
|
+
if (token = scanner.scan(/--[^\n]*(?:\n|\z)/))
|
|
137
|
+
output << token
|
|
138
|
+
elsif scanner.scan(%r{/\*})
|
|
139
|
+
output << "/*"
|
|
140
|
+
depth = 1
|
|
141
|
+
until scanner.eos? || depth.zero?
|
|
142
|
+
if scanner.scan(%r{/\*}) then output << "/*"; depth += 1
|
|
143
|
+
elsif scanner.scan(%r{\*/}) then output << "*/"; depth -= 1
|
|
144
|
+
else output << scanner.getch
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
elsif (start = scanner.scan(/(?:\b[Ee])?'|"/))
|
|
148
|
+
output << start
|
|
149
|
+
quote = start[-1]
|
|
150
|
+
escaped = start.length > 1
|
|
151
|
+
until scanner.eos?
|
|
152
|
+
char = scanner.getch
|
|
153
|
+
output << char
|
|
154
|
+
if escaped && char == "\\"
|
|
155
|
+
output << scanner.getch unless scanner.eos?
|
|
156
|
+
elsif char == quote
|
|
157
|
+
if scanner.peek(1) == quote then output << scanner.getch
|
|
158
|
+
else break
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
elsif (delimiter = scanner.scan(/\$(?:[A-Za-z_][A-Za-z_0-9]*)?\$/))
|
|
163
|
+
output << delimiter
|
|
164
|
+
output << (scanner.scan_until(/#{Regexp.escape(delimiter)}/) || scanner.rest.tap { scanner.terminate })
|
|
165
|
+
elsif (cast = scanner.scan(/::/))
|
|
166
|
+
output << cast
|
|
167
|
+
elsif named && (placeholder = scanner.scan(/:[A-Za-z_][A-Za-z_0-9]*/))
|
|
168
|
+
name = placeholder.delete_prefix(":")
|
|
169
|
+
raise ArgumentError, "missing parameter :#{name}" unless parameters.key?(name)
|
|
170
|
+
output << literal(parameters.fetch(name))
|
|
171
|
+
elsif !named && scanner.scan(/\?/)
|
|
172
|
+
raise ArgumentError, "more placeholders than parameters" if index >= parameters.length
|
|
173
|
+
output << literal(parameters[index])
|
|
174
|
+
index += 1
|
|
175
|
+
else
|
|
176
|
+
output << scanner.getch
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
raise ArgumentError, "#{index} placeholders for #{parameters.length} parameters" if !named && index != parameters.length
|
|
180
|
+
output
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
data/lib/quack/types.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Quack
|
|
2
|
+
# Time-of-day can represent 24:00:00 and nanoseconds without inventing a date.
|
|
3
|
+
TimeOfDay = Data.define(:nanoseconds, :utc_offset) do
|
|
4
|
+
def to_s
|
|
5
|
+
seconds, fraction = nanoseconds.divmod(1_000_000_000)
|
|
6
|
+
hours, seconds = seconds.divmod(3600)
|
|
7
|
+
minutes, seconds = seconds.divmod(60)
|
|
8
|
+
text = format("%02d:%02d:%02d", hours, minutes, seconds)
|
|
9
|
+
text += ".#{format('%09d', fraction).sub(/0+\z/, '')}" unless fraction.zero?
|
|
10
|
+
unless utc_offset.nil?
|
|
11
|
+
offset = utc_offset.abs
|
|
12
|
+
text += format("%s%02d:%02d:%02d", utc_offset.negative? ? "-" : "+", offset / 3600, offset / 60 % 60, offset % 60)
|
|
13
|
+
end
|
|
14
|
+
text
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
Interval = Data.define(:months, :days, :microseconds)
|
|
18
|
+
InfiniteTemporal = Data.define(:type, :sign)
|
|
19
|
+
|
|
20
|
+
def self.__decimal(text) = BigDecimal(text)
|
|
21
|
+
def self.__date(days)
|
|
22
|
+
return InfiniteTemporal.new("DATE", days.negative? ? -1 : 1) if [2**31 - 1, -(2**31 - 1)].include?(days)
|
|
23
|
+
Date.new(1970, 1, 1, Date::GREGORIAN) + days
|
|
24
|
+
end
|
|
25
|
+
def self.__time(value, unit) = TimeOfDay.new(unit == "Micros" ? value * 1000 : value, nil)
|
|
26
|
+
def self.__time_tz(bits)
|
|
27
|
+
unsigned = bits & ((1 << 64) - 1)
|
|
28
|
+
# DuckDB packs microseconds into the high 40 bits and a biased UTC offset
|
|
29
|
+
# (16 hours minus one second minus offset seconds) into the low 24 bits.
|
|
30
|
+
TimeOfDay.new((unsigned >> 24) * 1000, 57_599 - (unsigned & 0xffffff))
|
|
31
|
+
end
|
|
32
|
+
def self.__timestamp(value, unit, timezone_utc)
|
|
33
|
+
return InfiniteTemporal.new(timezone_utc ? "TIMESTAMPTZ" : "TIMESTAMP", value.negative? ? -1 : 1) if [2**63 - 1, -(2**63 - 1)].include?(value)
|
|
34
|
+
divisor = {"Seconds" => 1, "Millis" => 1000, "Micros" => 1_000_000, "Nanos" => 1_000_000_000}.fetch(unit)
|
|
35
|
+
Time.at(Rational(value, divisor)).utc
|
|
36
|
+
end
|
|
37
|
+
def self.__interval(months, days, micros) = Interval.new(months, days, micros)
|
|
38
|
+
|
|
39
|
+
def self.__pack(value, type) = Codec.pack(value, type)
|
|
40
|
+
end
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
module Quack
|
|
2
|
+
# A logical, typed tree. Node types preserve distinctions such as UBIGINT,
|
|
3
|
+
# BLOB versus VARCHAR, and timestamp units inside heterogeneous containers.
|
|
4
|
+
class Variant
|
|
5
|
+
Node = Data.define(:type, :value)
|
|
6
|
+
attr_reader :node
|
|
7
|
+
|
|
8
|
+
INTEGER_TYPES = {3 => ["TINYINT", 1], 4 => ["SMALLINT", 2], 5 => ["INTEGER", 4], 6 => ["BIGINT", 8], 7 => ["HUGEINT", 16],
|
|
9
|
+
8 => ["UTINYINT", 1], 9 => ["USMALLINT", 2], 10 => ["UINTEGER", 4], 11 => ["UBIGINT", 8], 12 => ["UHUGEINT", 16]}.freeze
|
|
10
|
+
TEMPORAL_TYPES = {20 => ["TIME", "Micros"], 21 => ["TIME_NS", "Nanos"],
|
|
11
|
+
22 => ["TIMESTAMP_S", "Seconds"], 23 => ["TIMESTAMP_MS", "Millis"],
|
|
12
|
+
24 => ["TIMESTAMP", "Micros"], 25 => ["TIMESTAMP_NS", "Nanos"],
|
|
13
|
+
27 => ["TIMESTAMPTZ", "Micros"], 34 => ["TIMESTAMPTZ_NS", "Nanos"]}.freeze
|
|
14
|
+
STRING_TYPES = {16 => "VARCHAR", 17 => "BLOB", 31 => "BIGNUM", 32 => "BIT", 33 => "GEOMETRY"}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(value, type: nil)
|
|
17
|
+
@node = value.is_a?(Node) ? value : self.class.make_node(value, type)
|
|
18
|
+
self.class.validate_node(@node)
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def type = node.type
|
|
23
|
+
def value = self.class.unwrap(node)
|
|
24
|
+
def ==(other) = other.is_a?(Variant) && node == other.node
|
|
25
|
+
alias eql? ==
|
|
26
|
+
def hash = [self.class, node].hash
|
|
27
|
+
|
|
28
|
+
def self.unwrap(node)
|
|
29
|
+
case node.type
|
|
30
|
+
when "OBJECT" then node.value.transform_values { |child| unwrap(child) }
|
|
31
|
+
when "ARRAY" then node.value.map { |child| unwrap(child) }
|
|
32
|
+
else node.value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.make_node(value, type = nil)
|
|
37
|
+
return value.node if value.is_a?(Variant)
|
|
38
|
+
return value if value.is_a?(Node)
|
|
39
|
+
type ||= case value
|
|
40
|
+
when nil then "NULL"
|
|
41
|
+
when true, false then "BOOLEAN"
|
|
42
|
+
when Integer then (-(2**63)...2**63).cover?(value) ? "BIGINT" : "BIGNUM"
|
|
43
|
+
when Float then "DOUBLE"
|
|
44
|
+
when BigDecimal
|
|
45
|
+
text = value.to_s("F").sub(/\.0\z/, "")
|
|
46
|
+
integer, fraction = text.delete_prefix("-").split(".")
|
|
47
|
+
scale = fraction.to_s.length
|
|
48
|
+
"DECIMAL(#{[integer.sub(/\A0+/, '').length + scale, 1].max},#{scale})"
|
|
49
|
+
when String then value.encoding == Encoding::BINARY ? "BLOB" : "VARCHAR"
|
|
50
|
+
when Timestamp then value.duckdb_type
|
|
51
|
+
when Time, DateTime then "TIMESTAMP_NS"
|
|
52
|
+
when Date then "DATE"
|
|
53
|
+
when TimeOfDay then value.utc_offset.nil? ? "TIME_NS" : "TIMETZ"
|
|
54
|
+
when Interval then "INTERVAL"
|
|
55
|
+
when InfiniteTemporal then value.type
|
|
56
|
+
when BitString then "BIT"
|
|
57
|
+
when Geometry then "GEOMETRY"
|
|
58
|
+
when Array, Tuple then "ARRAY"
|
|
59
|
+
when Hash then "OBJECT"
|
|
60
|
+
else raise TypeError, "unsupported VARIANT value #{value.class}"
|
|
61
|
+
end
|
|
62
|
+
type = String(type).upcase.freeze
|
|
63
|
+
children = case type
|
|
64
|
+
when "ARRAY" then (value.is_a?(Tuple) ? value.values : value).map { |child| make_node(child) }.freeze
|
|
65
|
+
when "OBJECT" then value.to_h.to_h { |key, child| [String(key).freeze, make_node(child)] }.freeze
|
|
66
|
+
else value
|
|
67
|
+
end
|
|
68
|
+
Node.new(type, children)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.validate_node(node, depth = 0)
|
|
72
|
+
raise ArgumentError, "VARIANT nesting exceeds 256 levels" if depth > 256
|
|
73
|
+
type = node.type
|
|
74
|
+
# Node is public; validate its names even when constructed directly.
|
|
75
|
+
valid = ["NULL", "BOOLEAN", "FLOAT", "DOUBLE", "UUID", "DATE", "TIMETZ", "INTERVAL", "OBJECT", "ARRAY"] + INTEGER_TYPES.values.map(&:first) + TEMPORAL_TYPES.values.map(&:first) + STRING_TYPES.values
|
|
76
|
+
unless valid.include?(type) || type.match?(/\ADECIMAL\((?:[1-9]|[12][0-9]|3[0-8]),(?:[0-9]|[12][0-9]|3[0-8])\)\z/)
|
|
77
|
+
raise ArgumentError, "unsupported VARIANT type #{type}"
|
|
78
|
+
end
|
|
79
|
+
case type
|
|
80
|
+
when "ARRAY" then node.value.each { |child| validate_node(child, depth + 1) }
|
|
81
|
+
when "OBJECT" then node.value.each_value { |child| validate_node(child, depth + 1) }
|
|
82
|
+
when "BOOLEAN"
|
|
83
|
+
raise TypeError, "VARIANT BOOLEAN must be true or false" unless node.value == true || node.value == false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class Reader
|
|
88
|
+
def initialize(data, offset)
|
|
89
|
+
@data, @offset = data, Integer(offset)
|
|
90
|
+
raise ProtocolError, "invalid VARIANT byte offset" unless (0..data.bytesize).cover?(@offset)
|
|
91
|
+
end
|
|
92
|
+
def bytes(count)
|
|
93
|
+
raise ProtocolError, "truncated VARIANT payload" if count.negative? || @offset + count > @data.bytesize
|
|
94
|
+
result = @data.byteslice(@offset, count)
|
|
95
|
+
@offset += count
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
def uint(count) = bytes(count).bytes.reverse.reduce(0) { |value, byte| (value << 8) | byte }
|
|
99
|
+
def int(count)
|
|
100
|
+
value = uint(count)
|
|
101
|
+
value >= 2**(count * 8 - 1) ? value - 2**(count * 8) : value
|
|
102
|
+
end
|
|
103
|
+
def varint
|
|
104
|
+
result = 0
|
|
105
|
+
5.times do |index|
|
|
106
|
+
byte = uint(1)
|
|
107
|
+
result |= (byte & 127) << (index * 7)
|
|
108
|
+
return result if byte < 128 && result < 2**32
|
|
109
|
+
end
|
|
110
|
+
raise ProtocolError, "invalid VARIANT varint"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.from_physical(physical)
|
|
115
|
+
decode_node = lambda do |index, ancestors|
|
|
116
|
+
raise ProtocolError, "cyclic or excessively nested VARIANT" if ancestors.include?(index) || ancestors.length > 256
|
|
117
|
+
item = physical.fetch("values").fetch(index)
|
|
118
|
+
id = item.fetch("type_id")
|
|
119
|
+
reader = Reader.new(physical.fetch("data"), item.fetch("byte_offset"))
|
|
120
|
+
type, value = if INTEGER_TYPES.key?(id)
|
|
121
|
+
name, size = INTEGER_TYPES.fetch(id)
|
|
122
|
+
[name, name.start_with?("U") ? reader.uint(size) : reader.int(size)]
|
|
123
|
+
elsif TEMPORAL_TYPES.key?(id)
|
|
124
|
+
name, unit = TEMPORAL_TYPES.fetch(id)
|
|
125
|
+
raw = reader.int(8)
|
|
126
|
+
converted = if name.start_with?("TIME") && !name.start_with?("TIMESTAMP") && !name.start_with?("TIMESTAMPTZ")
|
|
127
|
+
Quack.__time(raw, unit)
|
|
128
|
+
else
|
|
129
|
+
timestamp = Quack.__timestamp(raw, unit, name.start_with?("TIMESTAMPTZ"))
|
|
130
|
+
timestamp.is_a?(InfiniteTemporal) ? timestamp : Timestamp.from(timestamp, name)
|
|
131
|
+
end
|
|
132
|
+
[name, converted]
|
|
133
|
+
elsif STRING_TYPES.key?(id)
|
|
134
|
+
name = STRING_TYPES.fetch(id)
|
|
135
|
+
data = reader.bytes(reader.varint)
|
|
136
|
+
converted = case name
|
|
137
|
+
when "VARCHAR"
|
|
138
|
+
text = data.dup.force_encoding(Encoding::UTF_8)
|
|
139
|
+
raise ProtocolError, "invalid VARIANT UTF-8" unless text.valid_encoding?
|
|
140
|
+
text
|
|
141
|
+
when "BIGNUM" then Codec.decode_bignum(data)
|
|
142
|
+
when "BIT" then Codec.decode_bit(data)
|
|
143
|
+
when "GEOMETRY" then Geometry.new(data)
|
|
144
|
+
else data.b
|
|
145
|
+
end
|
|
146
|
+
[name, converted]
|
|
147
|
+
else
|
|
148
|
+
case id
|
|
149
|
+
when 0 then ["NULL", nil]
|
|
150
|
+
when 1, 2 then ["BOOLEAN", id == 1]
|
|
151
|
+
when 13 then ["FLOAT", reader.bytes(4).unpack1("e")]
|
|
152
|
+
when 14 then ["DOUBLE", reader.bytes(8).unpack1("E")]
|
|
153
|
+
when 15
|
|
154
|
+
width, scale = reader.varint, reader.varint
|
|
155
|
+
raise ProtocolError, "invalid VARIANT decimal metadata" unless (1..38).cover?(width) && scale <= width
|
|
156
|
+
size = width <= 4 ? 2 : width <= 9 ? 4 : width <= 18 ? 8 : 16
|
|
157
|
+
integer = reader.int(size)
|
|
158
|
+
digits = integer.abs.to_s.rjust(scale + 1, "0")
|
|
159
|
+
text = scale.zero? ? digits : "#{digits[0...-scale]}.#{digits[-scale..]}"
|
|
160
|
+
["DECIMAL(#{width},#{scale})", BigDecimal(integer.negative? ? "-#{text}" : text)]
|
|
161
|
+
when 18
|
|
162
|
+
hex = (reader.uint(16) ^ (1 << 127)).to_s(16).rjust(32, "0")
|
|
163
|
+
["UUID", [hex[0, 8], hex[8, 4], hex[12, 4], hex[16, 4], hex[20, 12]].join("-")]
|
|
164
|
+
when 19 then ["DATE", Quack.__date(reader.int(4))]
|
|
165
|
+
when 26 then ["TIMETZ", Quack.__time_tz(reader.int(8))]
|
|
166
|
+
when 28 then ["INTERVAL", Interval.new(reader.int(4), reader.int(4), reader.int(8))]
|
|
167
|
+
when 29, 30
|
|
168
|
+
count = reader.varint
|
|
169
|
+
first = count.zero? ? 0 : reader.varint
|
|
170
|
+
children = physical.fetch("children")
|
|
171
|
+
raise ProtocolError, "invalid VARIANT child range" if first + count > children.length
|
|
172
|
+
items = children.slice(first, count).map do |child|
|
|
173
|
+
node = decode_node.call(child.fetch("values_index"), ancestors + [index])
|
|
174
|
+
id == 29 ? [physical.fetch("keys").fetch(child.fetch("keys_index")), node] : node
|
|
175
|
+
end
|
|
176
|
+
[id == 29 ? "OBJECT" : "ARRAY", id == 29 ? items.to_h.freeze : items.freeze]
|
|
177
|
+
else raise UnsupportedTypeError, "unsupported VARIANT logical type #{id}"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
Node.new(type, value)
|
|
181
|
+
end
|
|
182
|
+
node = decode_node.call(0, [])
|
|
183
|
+
node.type == "NULL" ? nil : new(node)
|
|
184
|
+
rescue KeyError, IndexError, TypeError => error
|
|
185
|
+
raise ProtocolError, "invalid VARIANT structure: #{error.message}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def to_sql
|
|
189
|
+
self.class.validate_node(node)
|
|
190
|
+
self.class.node_sql(node)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.node_sql(node)
|
|
194
|
+
case node.type
|
|
195
|
+
when "NULL" then "('null'::JSON)::VARIANT"
|
|
196
|
+
when "ARRAY" then "[#{node.value.map { |child| node_sql(child) }.join(', ')}]::VARIANT"
|
|
197
|
+
when "OBJECT"
|
|
198
|
+
return "('{}'::JSON)::VARIANT" if node.value.empty?
|
|
199
|
+
"{#{node.value.map { |key, child| "#{SQL.literal(key)}: #{node_sql(child)}" }.join(', ')}}::VARIANT"
|
|
200
|
+
else
|
|
201
|
+
literal = node.type.start_with?("TIMESTAMP") || node.type.start_with?("TIMESTAMPTZ") ? SQL.timestamp_literal(node.value, node.type) : SQL.literal(node.value)
|
|
202
|
+
"((#{literal})::#{node.type})::VARIANT"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def self.varint(value)
|
|
207
|
+
raise RangeError, "VARIANT length out of range" unless (0...2**32).cover?(value)
|
|
208
|
+
bytes = []
|
|
209
|
+
loop do
|
|
210
|
+
byte = value & 127
|
|
211
|
+
value >>= 7
|
|
212
|
+
bytes << (value.zero? ? byte : byte | 128)
|
|
213
|
+
break if value.zero?
|
|
214
|
+
end
|
|
215
|
+
bytes.pack("C*")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def self.integer_bytes(value, size, signed: true)
|
|
219
|
+
range = signed ? (-(2**(size * 8 - 1))...2**(size * 8 - 1)) : (0...2**(size * 8))
|
|
220
|
+
raise RangeError, "VARIANT integer out of range" unless value.is_a?(Integer) && range.cover?(value)
|
|
221
|
+
Array.new(size) { |index| (value >> (index * 8)) & 255 }.pack("C*")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def to_physical
|
|
225
|
+
keys = []
|
|
226
|
+
visit_keys = lambda do |node|
|
|
227
|
+
case node.type
|
|
228
|
+
when "OBJECT"
|
|
229
|
+
keys.concat(node.value.keys)
|
|
230
|
+
node.value.each_value { |child| visit_keys.call(child) }
|
|
231
|
+
when "ARRAY" then node.value.each { |child| visit_keys.call(child) }
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
visit_keys.call(node)
|
|
235
|
+
keys = keys.uniq.sort
|
|
236
|
+
key_indices = keys.each_with_index.to_h
|
|
237
|
+
physical = {"keys" => keys, "children" => [], "values" => [], "data" => +"".b}
|
|
238
|
+
encode = lambda do |node|
|
|
239
|
+
index = physical.fetch("values").length
|
|
240
|
+
physical.fetch("values") << nil
|
|
241
|
+
type, value = node.type, node.value
|
|
242
|
+
id, bytes = if (entry = INTEGER_TYPES.find { |_id, (name, _size)| name == type })
|
|
243
|
+
[entry.first, self.class.integer_bytes(value, entry.last.last, signed: !type.start_with?("U"))]
|
|
244
|
+
elsif (entry = TEMPORAL_TYPES.find { |_id, (name, _unit)| name == type })
|
|
245
|
+
spec = {"id" => Codec::SQL_NAMES.key(type) || type.capitalize}
|
|
246
|
+
spec["id"] = "Timestamp" if type == "TIMESTAMP"
|
|
247
|
+
packed = Codec.pack(value, spec)
|
|
248
|
+
[entry.first, self.class.integer_bytes(packed[1], 8)]
|
|
249
|
+
elsif (entry = STRING_TYPES.find { |_id, name| name == type })
|
|
250
|
+
payload = case type
|
|
251
|
+
when "BIGNUM" then Codec.encode_bignum(value)
|
|
252
|
+
when "BIT" then Codec.encode_bit(value)
|
|
253
|
+
when "GEOMETRY" then value.wkb
|
|
254
|
+
else String(value).b
|
|
255
|
+
end
|
|
256
|
+
[entry.first, self.class.varint(payload.bytesize) + payload]
|
|
257
|
+
else
|
|
258
|
+
case type
|
|
259
|
+
when "NULL" then [0, "".b]
|
|
260
|
+
when "BOOLEAN" then [value ? 1 : 2, "".b]
|
|
261
|
+
when "FLOAT" then [13, [value].pack("e")]
|
|
262
|
+
when "DOUBLE" then [14, [value].pack("E")]
|
|
263
|
+
when /\ADECIMAL\((\d+),(\d+)\)\z/
|
|
264
|
+
width, scale = Regexp.last_match.captures.map(&:to_i)
|
|
265
|
+
packed = Codec.pack(value, {"id" => "Decimal", "width" => width, "scale" => scale})
|
|
266
|
+
integer = (BigDecimal(packed[1]) * 10**scale).to_i
|
|
267
|
+
size = width <= 4 ? 2 : width <= 9 ? 4 : width <= 18 ? 8 : 16
|
|
268
|
+
[15, self.class.varint(width) + self.class.varint(scale) + self.class.integer_bytes(integer, size)]
|
|
269
|
+
when "UUID"
|
|
270
|
+
hex = String(value).delete("-")
|
|
271
|
+
raise ArgumentError, "invalid VARIANT UUID" unless hex.match?(/\A[0-9a-fA-F]{32}\z/)
|
|
272
|
+
[18, self.class.integer_bytes(hex.to_i(16) ^ (1 << 127), 16, signed: false)]
|
|
273
|
+
when "DATE"
|
|
274
|
+
[19, self.class.integer_bytes(Codec.pack(value, {"id" => "Date"})[1], 4)]
|
|
275
|
+
when "TIMETZ"
|
|
276
|
+
[26, self.class.integer_bytes(Codec.pack(value, {"id" => "TimeTz"})[1], 8)]
|
|
277
|
+
when "INTERVAL"
|
|
278
|
+
[28, self.class.integer_bytes(value.months, 4) + self.class.integer_bytes(value.days, 4) + self.class.integer_bytes(value.microseconds, 8)]
|
|
279
|
+
when "OBJECT", "ARRAY"
|
|
280
|
+
children = type == "OBJECT" ? value.to_a : value.map { |child| [nil, child] }
|
|
281
|
+
first = physical.fetch("children").length
|
|
282
|
+
physical.fetch("children").concat(Array.new(children.length))
|
|
283
|
+
children.each_with_index do |(key, child), offset|
|
|
284
|
+
physical.fetch("children")[first + offset] = {"keys_index" => key && key_indices.fetch(key), "values_index" => encode.call(child)}
|
|
285
|
+
end
|
|
286
|
+
[type == "OBJECT" ? 29 : 30, self.class.varint(children.length) + (children.empty? ? "".b : self.class.varint(first))]
|
|
287
|
+
else raise ArgumentError, "unsupported VARIANT type #{type}"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
physical.fetch("values")[index] = {"type_id" => id, "byte_offset" => physical.fetch("data").bytesize}
|
|
291
|
+
physical.fetch("data") << bytes
|
|
292
|
+
index
|
|
293
|
+
end
|
|
294
|
+
encode.call(node)
|
|
295
|
+
physical
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|