sequel-duckdb 0.3.0 → 0.4.0
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 +4 -4
- data/CHANGELOG.md +174 -2
- data/README.md +121 -0
- data/lib/sequel/adapters/duckdb.rb +111 -14
- data/lib/sequel/adapters/shared/duckdb.rb +286 -149
- data/lib/sequel/duckdb/helpers/copier.rb +30 -7
- data/lib/sequel/duckdb/helpers/pathifier.rb +1 -1
- data/lib/sequel/duckdb/instance_registry.rb +114 -0
- data/lib/sequel/duckdb/sql_time_tz.rb +31 -0
- data/lib/sequel/duckdb/version.rb +1 -1
- data/lib/sequel/extensions/duckdb_array.rb +149 -0
- data/lib/sequel/extensions/duckdb_json.rb +130 -0
- data/lib/sequel/extensions/duckdb_map.rb +93 -0
- data/lib/sequel/extensions/duckdb_ops.rb +219 -0
- data/lib/sequel/extensions/duckdb_struct.rb +92 -0
- metadata +26 -4
|
@@ -4,7 +4,14 @@ module Sequel
|
|
|
4
4
|
module DuckDB
|
|
5
5
|
module Helpers
|
|
6
6
|
# Builds COPY SQL statements for exporting DuckDB query results to files.
|
|
7
|
+
#
|
|
8
|
+
# FORMAT comes from the +format:+ option when given, otherwise from the
|
|
9
|
+
# destination's file extension. A destination with neither (for example a
|
|
10
|
+
# directory for a partitioned COPY) raises Sequel::Error.
|
|
7
11
|
class Copier
|
|
12
|
+
# Option values that DuckDB accepts as a bare keyword (FORMAT PARQUET).
|
|
13
|
+
BARE_WORD = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
14
|
+
|
|
8
15
|
def initialize(src, dst, options = Sequel::OPTS)
|
|
9
16
|
@src = src
|
|
10
17
|
@dst = dst
|
|
@@ -12,7 +19,7 @@ module Sequel
|
|
|
12
19
|
end
|
|
13
20
|
|
|
14
21
|
def to_sql
|
|
15
|
-
"COPY (#{source}) TO
|
|
22
|
+
"COPY (#{source}) TO #{string_literal(@dst)} #{options_str}"
|
|
16
23
|
end
|
|
17
24
|
|
|
18
25
|
def source
|
|
@@ -23,26 +30,42 @@ module Sequel
|
|
|
23
30
|
end
|
|
24
31
|
end
|
|
25
32
|
|
|
26
|
-
def destination
|
|
27
|
-
Pathname.new(@dst).expand_path.to_s
|
|
28
|
-
end
|
|
29
|
-
|
|
30
33
|
def options_str
|
|
31
34
|
opts_str = { format: format }.merge(@options).map { |k, v| format_option(k, v) }.compact.join(", ").strip
|
|
32
35
|
opts_str.empty? ? "" : "(#{opts_str})"
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
def format_option(key, value)
|
|
39
|
+
return "PARTITION_BY (#{Array(value).map { |name| quoted_identifier(name) }.join(", ")})" if key.to_s == "partition_by"
|
|
40
|
+
|
|
36
41
|
key = key.to_s.upcase
|
|
37
42
|
case value
|
|
38
43
|
when true then key
|
|
39
44
|
when false then nil
|
|
40
|
-
|
|
45
|
+
when Integer, Float then "#{key} #{value}"
|
|
46
|
+
when BARE_WORD then "#{key} #{value.to_s.upcase}"
|
|
47
|
+
else "#{key} #{string_literal(value)}"
|
|
41
48
|
end
|
|
42
49
|
end
|
|
43
50
|
|
|
44
51
|
def format
|
|
45
|
-
|
|
52
|
+
@options.fetch(:format) do
|
|
53
|
+
File.extname(@dst.to_s).delete_prefix(".").then do |ext|
|
|
54
|
+
raise Sequel::Error, "Cannot infer COPY format from #{@dst.to_s.inspect}: it has no file extension. Pass format:, for example format: :parquet" if ext.empty?
|
|
55
|
+
|
|
56
|
+
ext
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def string_literal(value)
|
|
64
|
+
"'#{value.to_s.gsub("'", "''")}'"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def quoted_identifier(name)
|
|
68
|
+
"\"#{name.to_s.gsub('"', '""')}\""
|
|
46
69
|
end
|
|
47
70
|
end
|
|
48
71
|
end
|
|
@@ -120,7 +120,7 @@ module Sequel
|
|
|
120
120
|
# db.literal(sql)
|
|
121
121
|
# # => "read_csv(['/data/a.csv','/data/b.csv'])"
|
|
122
122
|
def to_sql
|
|
123
|
-
paths_sql = @paths.map { |p| "'#{p}'" }.join(",").then do |paths_arr|
|
|
123
|
+
paths_sql = @paths.map { |p| "'#{p.to_s.gsub("'", "''")}'" }.join(",").then do |paths_arr|
|
|
124
124
|
Sequel::LiteralString.new("[#{paths_arr}]")
|
|
125
125
|
end
|
|
126
126
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sequel
|
|
4
|
+
module DuckDB
|
|
5
|
+
# One ::DuckDB::Database per database file per process.
|
|
6
|
+
#
|
|
7
|
+
# Two ::DuckDB::Database instances on one file in one process do not reliably see
|
|
8
|
+
# each other's writes, and DuckDB's file lock does not stop the second one, because
|
|
9
|
+
# fcntl locks are per process. So every Sequel::Database on a file shares the one
|
|
10
|
+
# instance held here, counted by reference. The instance closes when the last
|
|
11
|
+
# reference is released.
|
|
12
|
+
#
|
|
13
|
+
# This does not use ::DuckDB::InstanceCache. In ruby-duckdb 1.5.5.1 it returns one
|
|
14
|
+
# Ruby object to every holder, and get_or_create hangs when called again after that
|
|
15
|
+
# object was closed (seed sequel-duckdb-k82).
|
|
16
|
+
#
|
|
17
|
+
# The first Sequel::Database to open a file fixes its DuckDB settings until the
|
|
18
|
+
# instance closes. Opening it again with different settings raises SettingsConflict,
|
|
19
|
+
# as the Python, JDBC and R DuckDB clients do.
|
|
20
|
+
#
|
|
21
|
+
# In-memory databases are never registered: each one is separate by design.
|
|
22
|
+
module InstanceRegistry
|
|
23
|
+
Entry = Struct.new(:database, :references, :settings)
|
|
24
|
+
|
|
25
|
+
# Raised when a file that is open in this process is opened with different settings.
|
|
26
|
+
class SettingsConflict < Sequel::DatabaseConnectionError
|
|
27
|
+
def initialize(path, held, requested)
|
|
28
|
+
super("#{path} is already open in this process with different DuckDB settings " \
|
|
29
|
+
"(open: #{held.inspect}, requested: #{requested.inspect}). The first " \
|
|
30
|
+
"Sequel::Database to open a file fixes its settings until every " \
|
|
31
|
+
"Sequel::Database on it has disconnected.")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
@entries = {}
|
|
37
|
+
@pid = Process.pid
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
# Open a ::DuckDB::Database that is not shared. +settings+ maps DuckDB setting
|
|
41
|
+
# names to values, both Strings (see normalize_settings).
|
|
42
|
+
def open(path, settings)
|
|
43
|
+
return ::DuckDB::Database.open(path) if settings.empty?
|
|
44
|
+
|
|
45
|
+
config = ::DuckDB::Config.new
|
|
46
|
+
settings.each { |name, value| config.set_config(name, value) }
|
|
47
|
+
::DuckDB::Database.open(path, config: config)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The settings Hash in the one form that acquire compares.
|
|
51
|
+
def normalize_settings(settings)
|
|
52
|
+
(settings || {}).to_h { |name, value| [ name.to_s, value.to_s ] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Return the shared ::DuckDB::Database for +path+ and take one reference to it.
|
|
56
|
+
# Raises SettingsConflict when +path+ is already open with other +settings+.
|
|
57
|
+
def acquire(path, settings = {})
|
|
58
|
+
key = key_for(path)
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
forget_parent_entries
|
|
61
|
+
entry = (@entries[key] ||= Entry.new(open(path, settings), 0, settings))
|
|
62
|
+
raise SettingsConflict.new(path, entry.settings, settings) unless entry.settings == settings
|
|
63
|
+
|
|
64
|
+
entry.references += 1
|
|
65
|
+
entry.database
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Give back one reference taken by acquire. The last one closes the instance.
|
|
70
|
+
def release(path)
|
|
71
|
+
key = key_for(path)
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
forget_parent_entries
|
|
74
|
+
entry = @entries[key]
|
|
75
|
+
return unless entry
|
|
76
|
+
|
|
77
|
+
entry.references -= 1
|
|
78
|
+
return if entry.references.positive?
|
|
79
|
+
|
|
80
|
+
@entries.delete(key)
|
|
81
|
+
entry.database.close
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The number of references held on +path+, 0 when it is not open.
|
|
86
|
+
def references(path)
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
forget_parent_entries
|
|
89
|
+
@entries[key_for(path)]&.references || 0
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# The real path of the file's directory, so two spellings of one file share an
|
|
96
|
+
# entry. The file itself may not exist yet.
|
|
97
|
+
def key_for(path)
|
|
98
|
+
File.join(File.realpath(File.dirname(path)), File.basename(path))
|
|
99
|
+
rescue SystemCallError
|
|
100
|
+
File.expand_path(path)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A forked child inherits the parent's entries, but the instances belong to the
|
|
104
|
+
# parent. The child opens its own, as it did before this registry existed.
|
|
105
|
+
def forget_parent_entries
|
|
106
|
+
return if @pid == Process.pid
|
|
107
|
+
|
|
108
|
+
@entries = {}
|
|
109
|
+
@pid = Process.pid
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sequel
|
|
4
|
+
module DuckDB
|
|
5
|
+
# A TIMETZ value: a time of day with a UTC offset.
|
|
6
|
+
#
|
|
7
|
+
# Sequel::SQLTime has no offset, so a TIMETZ value written as one gets DuckDB's
|
|
8
|
+
# session time zone. This subclass keeps the offset and is written as
|
|
9
|
+
# 'HH:MM:SS.ffffff+HH:MM'. Like Sequel::SQLTime, its date is Sequel::SQLTime.date,
|
|
10
|
+
# so two values with the same time and offset are equal.
|
|
11
|
+
class SQLTimeTZ < Sequel::SQLTime
|
|
12
|
+
# The time of day and UTC offset of +time+, on Sequel::SQLTime.date.
|
|
13
|
+
def self.from_time(time)
|
|
14
|
+
date = Sequel::SQLTime.date
|
|
15
|
+
new(date.year, date.month, date.day, time.hour, time.min,
|
|
16
|
+
time.sec + Rational(time.nsec, 1_000_000_000), time.utc_offset)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Two values with the same instant but different offsets are different TIMETZ
|
|
20
|
+
# values, so a model that is assigned a new offset reports the column as changed.
|
|
21
|
+
def ==(other)
|
|
22
|
+
super && other.respond_to?(:utc_offset) && utc_offset == other.utc_offset
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# HH:MM:SS+HH:MM
|
|
26
|
+
def to_s(*args)
|
|
27
|
+
args.empty? ? strftime("%H:%M:%S%:z") : super
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The duckdb_array extension writes Ruby arrays to DuckDB LIST and ARRAY columns and
|
|
4
|
+
# reads them back as Sequel::DuckDB::DuckDBArray objects. It follows Sequel's
|
|
5
|
+
# pg_array extension. It is opt-in: without it, the adapter's SQL and values do not
|
|
6
|
+
# change.
|
|
7
|
+
#
|
|
8
|
+
# Load the constructor globally:
|
|
9
|
+
#
|
|
10
|
+
# Sequel.extension :duckdb_array
|
|
11
|
+
#
|
|
12
|
+
# Load it into a Database, which also converts results, schema types and model
|
|
13
|
+
# typecasting for that Database:
|
|
14
|
+
#
|
|
15
|
+
# DB.extension :duckdb_array
|
|
16
|
+
#
|
|
17
|
+
# Write a list:
|
|
18
|
+
#
|
|
19
|
+
# DB[:t].insert(l: Sequel.duckdb_array([1, 2])) # INSERT ... VALUES ([1, 2])
|
|
20
|
+
# Sequel.duckdb_array([[1], []]) # [[1], []]
|
|
21
|
+
#
|
|
22
|
+
# A plain Ruby Array still means an IN list or a condition in Sequel. Only a
|
|
23
|
+
# DuckDBArray is written as a DuckDB list.
|
|
24
|
+
#
|
|
25
|
+
# With the extension loaded into a Database:
|
|
26
|
+
#
|
|
27
|
+
# * LIST and ARRAY result values (INTEGER[], INTEGER[3]) are DuckDBArray objects.
|
|
28
|
+
# Only the top-level value is wrapped. A value nested in it stays as ruby-duckdb
|
|
29
|
+
# returns it.
|
|
30
|
+
# * db.schema reports an INTEGER[] or INTEGER[3] column as :integer_array. A column
|
|
31
|
+
# whose element type has no Sequel type, for example a STRUCT without the
|
|
32
|
+
# duckdb_struct extension, is reported as :array.
|
|
33
|
+
# * A model typecasts an Array assigned to such a column to a DuckDBArray, and
|
|
34
|
+
# typecasts each element with the element's type.
|
|
35
|
+
|
|
36
|
+
require "delegate"
|
|
37
|
+
|
|
38
|
+
module Sequel
|
|
39
|
+
module DuckDB
|
|
40
|
+
# An Array that Sequel writes as a DuckDB list literal.
|
|
41
|
+
class DuckDBArray < DelegateClass(Array)
|
|
42
|
+
# Element types that get a :<type>_array schema type.
|
|
43
|
+
ELEMENT_TYPES = %i[string integer bigint float decimal boolean date datetime time interval blob uuid struct
|
|
44
|
+
map json].freeze
|
|
45
|
+
|
|
46
|
+
# Element types whose typecast method has another name.
|
|
47
|
+
ELEMENT_TYPECASTS = { bigint: :integer }.freeze
|
|
48
|
+
|
|
49
|
+
# The element type and the dimensions of a LIST or ARRAY db_type:
|
|
50
|
+
# INTEGER[], INTEGER[3], INTEGER[][], STRUCT(a INTEGER)[].
|
|
51
|
+
DB_TYPE = /\A(.+?)(?:\[\d*\])+\z/m
|
|
52
|
+
|
|
53
|
+
# Element db_types that the adapter maps to :string only as its fallback.
|
|
54
|
+
COMPOSITE_DB_TYPE = /\A(?:STRUCT|MAP|UNION)\(/i
|
|
55
|
+
|
|
56
|
+
module DatabaseMethods
|
|
57
|
+
def self.extended(db)
|
|
58
|
+
db.instance_exec do
|
|
59
|
+
(ELEMENT_TYPES.map { |type| :"#{type}_array" } + [ :array ]).each do |type|
|
|
60
|
+
@schema_type_classes[type] = DuckDBArray
|
|
61
|
+
end
|
|
62
|
+
if respond_to?(:add_conversion_proc)
|
|
63
|
+
to_array = DuckDBArray.method(:new)
|
|
64
|
+
add_conversion_proc(:list, to_array)
|
|
65
|
+
add_conversion_proc(:array, to_array)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
ELEMENT_TYPES.each do |type|
|
|
71
|
+
method_name = :"typecast_value_#{type}_array"
|
|
72
|
+
element_typecast = :"typecast_value_#{ELEMENT_TYPECASTS.fetch(type, type)}"
|
|
73
|
+
define_method(method_name) { |value| typecast_value_duckdb_array(value, element_typecast) }
|
|
74
|
+
private method_name
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def typecast_value_array(value)
|
|
80
|
+
typecast_value_duckdb_array(value, nil)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def map_duckdb_type_to_sequel(duckdb_type)
|
|
84
|
+
element = duckdb_type[DB_TYPE, 1]
|
|
85
|
+
return super unless element
|
|
86
|
+
|
|
87
|
+
type = map_duckdb_type_to_sequel(element)
|
|
88
|
+
if ELEMENT_TYPES.include?(type) && !(type == :string && element.match?(COMPOSITE_DB_TYPE))
|
|
89
|
+
:"#{type}_array"
|
|
90
|
+
else
|
|
91
|
+
:array
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def typecast_value_duckdb_array(value, element_typecast)
|
|
96
|
+
raise Sequel::InvalidValue, "invalid value for array type: #{value.inspect}" unless value.is_a?(Array) || value.is_a?(DuckDBArray)
|
|
97
|
+
|
|
98
|
+
element_typecast = nil unless element_typecast && respond_to?(element_typecast, true)
|
|
99
|
+
DuckDBArray.new(typecast_duckdb_array_elements(value, element_typecast))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Nested arrays become plain Arrays. A nil element stays nil.
|
|
103
|
+
def typecast_duckdb_array_elements(array, element_typecast)
|
|
104
|
+
array.map do |element|
|
|
105
|
+
if element.is_a?(Array) || element.is_a?(DuckDBArray)
|
|
106
|
+
typecast_duckdb_array_elements(element, element_typecast)
|
|
107
|
+
elsif element.nil? || element_typecast.nil?
|
|
108
|
+
element
|
|
109
|
+
else
|
|
110
|
+
send(element_typecast, element)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def initialize(array)
|
|
117
|
+
super(array.to_ary)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# [1, 2]. A nested Array gives a nested list, and every other element goes
|
|
121
|
+
# through the dataset's literal_append.
|
|
122
|
+
def sql_literal_append(ds, sql)
|
|
123
|
+
append_list(ds, sql, __getobj__)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def append_list(ds, sql, array)
|
|
129
|
+
sql << "["
|
|
130
|
+
array.each_with_index do |element, i|
|
|
131
|
+
sql << ", " unless i.zero?
|
|
132
|
+
element.is_a?(Array) ? append_list(ds, sql, element) : ds.literal_append(sql, element)
|
|
133
|
+
end
|
|
134
|
+
sql << "]"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
module SQL
|
|
140
|
+
module Builders
|
|
141
|
+
# Wraps an Array, so that Sequel writes it as a DuckDB list.
|
|
142
|
+
def duckdb_array(array)
|
|
143
|
+
Sequel::DuckDB::DuckDBArray.new(array)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
Database.register_extension(:duckdb_array, DuckDB::DuckDBArray::DatabaseMethods)
|
|
149
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The duckdb_json extension writes Ruby hashes and arrays to DuckDB JSON columns and
|
|
4
|
+
# reads them back as Sequel::DuckDB::DuckDBJSONHash and DuckDBJSONArray objects. It
|
|
5
|
+
# follows Sequel's pg_json extension. It is opt-in: without it, the adapter's SQL and
|
|
6
|
+
# values do not change.
|
|
7
|
+
#
|
|
8
|
+
# Load the constructor globally:
|
|
9
|
+
#
|
|
10
|
+
# Sequel.extension :duckdb_json
|
|
11
|
+
#
|
|
12
|
+
# Load it into a Database, which also converts results, schema types and model
|
|
13
|
+
# typecasting for that Database:
|
|
14
|
+
#
|
|
15
|
+
# DB.extension :duckdb_json
|
|
16
|
+
#
|
|
17
|
+
# Write a JSON value:
|
|
18
|
+
#
|
|
19
|
+
# DB[:t].insert(j: Sequel.duckdb_json("a" => 1)) # INSERT ... VALUES ('{"a":1}'::JSON)
|
|
20
|
+
# Sequel.duckdb_json([1, 2]) # '[1,2]'::JSON
|
|
21
|
+
#
|
|
22
|
+
# A plain Ruby Hash or Array still means a condition or an IN list in Sequel. Only a
|
|
23
|
+
# DuckDBJSONHash or DuckDBJSONArray is written as JSON.
|
|
24
|
+
#
|
|
25
|
+
# With the extension loaded into a Database:
|
|
26
|
+
#
|
|
27
|
+
# * JSON result values are parsed. A JSON object becomes a DuckDBJSONHash with String
|
|
28
|
+
# keys, and a JSON array becomes a DuckDBJSONArray. A JSON string, number, boolean
|
|
29
|
+
# or null becomes the plain Ruby value. Only the top-level value is wrapped. A
|
|
30
|
+
# VARCHAR column is not parsed, even when it holds JSON text.
|
|
31
|
+
# * db.schema reports a JSON column as :json. With the duckdb_array extension also
|
|
32
|
+
# loaded, it reports a JSON[] column as :json_array.
|
|
33
|
+
# * A model typecasts a Hash or an Array assigned to a JSON column to the wrapper. It
|
|
34
|
+
# parses a String as JSON text.
|
|
35
|
+
|
|
36
|
+
require "delegate"
|
|
37
|
+
require "json"
|
|
38
|
+
|
|
39
|
+
module Sequel
|
|
40
|
+
module DuckDB
|
|
41
|
+
# Included in DuckDBJSONHash and DuckDBJSONArray, so that one check finds both.
|
|
42
|
+
module DuckDBJSONObject
|
|
43
|
+
# The wrapper for a parsed JSON value. A JSON string, number, boolean or null
|
|
44
|
+
# stays the plain Ruby value.
|
|
45
|
+
def self.wrap(value)
|
|
46
|
+
case value
|
|
47
|
+
when Hash then DuckDBJSONHash.new(value)
|
|
48
|
+
when Array then DuckDBJSONArray.new(value)
|
|
49
|
+
else value
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Not Sequel.parse_json: it passes create_additions, which json 3.0 rejects.
|
|
54
|
+
# JSON.parse creates no additions by default.
|
|
55
|
+
def self.parse(json)
|
|
56
|
+
wrap(::JSON.parse(json))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
module DatabaseMethods
|
|
60
|
+
def self.extended(db)
|
|
61
|
+
db.instance_exec do
|
|
62
|
+
@schema_type_classes[:json] = [ DuckDBJSONObject ]
|
|
63
|
+
add_conversion_proc(:json, DuckDBJSONObject.method(:parse)) if respond_to?(:add_conversion_proc)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def map_duckdb_type_to_sequel(duckdb_type)
|
|
70
|
+
duckdb_type.casecmp?("JSON") ? :json : super
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def typecast_value_json(value)
|
|
74
|
+
case value
|
|
75
|
+
when DuckDBJSONObject then value
|
|
76
|
+
when Hash, Array then DuckDBJSONObject.wrap(value)
|
|
77
|
+
when String then parse_json_for_typecast(value)
|
|
78
|
+
else raise Sequel::InvalidValue, "invalid value for json type: #{value.inspect}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def parse_json_for_typecast(json)
|
|
83
|
+
DuckDBJSONObject.parse(json)
|
|
84
|
+
rescue ::JSON::ParserError => e
|
|
85
|
+
raise Sequel.convert_exception_class(e, Sequel::InvalidValue)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# '{"a":1}'::JSON. The JSON text is a string literal, cast to JSON.
|
|
90
|
+
def sql_literal_append(ds, sql)
|
|
91
|
+
ds.literal_append(sql, Sequel.object_to_json(__getobj__))
|
|
92
|
+
sql << "::JSON"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A Hash that Sequel writes as a DuckDB JSON object.
|
|
97
|
+
class DuckDBJSONHash < DelegateClass(Hash)
|
|
98
|
+
include DuckDBJSONObject
|
|
99
|
+
|
|
100
|
+
def initialize(hash)
|
|
101
|
+
super(hash.to_hash)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# An Array that Sequel writes as a DuckDB JSON array.
|
|
106
|
+
class DuckDBJSONArray < DelegateClass(Array)
|
|
107
|
+
include DuckDBJSONObject
|
|
108
|
+
|
|
109
|
+
def initialize(array)
|
|
110
|
+
super(array.to_ary)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
module SQL
|
|
116
|
+
module Builders
|
|
117
|
+
# Wraps a Hash or an Array, so that Sequel writes it as DuckDB JSON. A wrapper
|
|
118
|
+
# is returned as it is.
|
|
119
|
+
def duckdb_json(value)
|
|
120
|
+
case value
|
|
121
|
+
when Sequel::DuckDB::DuckDBJSONObject then value
|
|
122
|
+
when Hash, Array then Sequel::DuckDB::DuckDBJSONObject.wrap(value)
|
|
123
|
+
else raise Sequel::Error, "Sequel.duckdb_json needs a Hash or an Array: #{value.inspect}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
Database.register_extension(:duckdb_json, DuckDB::DuckDBJSONObject::DatabaseMethods)
|
|
130
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The duckdb_map extension writes Ruby hashes to DuckDB MAP columns and reads them
|
|
4
|
+
# back as Sequel::DuckDB::DuckDBMap objects. Sequel's pg_hstore extension is the
|
|
5
|
+
# PostgreSQL counterpart, but a DuckDB MAP has typed keys and values, not only
|
|
6
|
+
# strings. It is opt-in: without it, the adapter's SQL and values do not change.
|
|
7
|
+
#
|
|
8
|
+
# Load the constructor globally:
|
|
9
|
+
#
|
|
10
|
+
# Sequel.extension :duckdb_map
|
|
11
|
+
#
|
|
12
|
+
# Load it into a Database, which also converts results, schema types and model
|
|
13
|
+
# typecasting for that Database:
|
|
14
|
+
#
|
|
15
|
+
# DB.extension :duckdb_map
|
|
16
|
+
#
|
|
17
|
+
# Write a map:
|
|
18
|
+
#
|
|
19
|
+
# DB[:t].insert(m: Sequel.duckdb_map("k" => 1)) # INSERT ... VALUES (MAP {'k': 1})
|
|
20
|
+
# Sequel.duckdb_map({}) # MAP {}
|
|
21
|
+
#
|
|
22
|
+
# A plain Ruby Hash still means a condition in Sequel. Only a DuckDBMap is written as
|
|
23
|
+
# a DuckDB map. A struct or list inside a map must itself be a wrapper.
|
|
24
|
+
#
|
|
25
|
+
# With the extension loaded into a Database:
|
|
26
|
+
#
|
|
27
|
+
# * MAP result values are DuckDBMap objects. The keys keep their Ruby type: a
|
|
28
|
+
# MAP(VARCHAR, INTEGER) has String keys. Only the top-level value is wrapped. A
|
|
29
|
+
# value nested in it stays as ruby-duckdb returns it.
|
|
30
|
+
# * db.schema reports a MAP column, for example MAP(VARCHAR, INTEGER), as :map.
|
|
31
|
+
# * A model typecasts a Hash assigned to a MAP column to a DuckDBMap.
|
|
32
|
+
|
|
33
|
+
require "delegate"
|
|
34
|
+
|
|
35
|
+
module Sequel
|
|
36
|
+
module DuckDB
|
|
37
|
+
# A Hash that Sequel writes as a DuckDB map literal.
|
|
38
|
+
class DuckDBMap < DelegateClass(Hash)
|
|
39
|
+
# A MAP db_type. MAP(VARCHAR, INTEGER)[] is a list, so it does not match.
|
|
40
|
+
DB_TYPE = /\AMAP\(.*\)\z/im
|
|
41
|
+
|
|
42
|
+
module DatabaseMethods
|
|
43
|
+
def self.extended(db)
|
|
44
|
+
db.instance_exec do
|
|
45
|
+
@schema_type_classes[:map] = DuckDBMap
|
|
46
|
+
add_conversion_proc(:map, DuckDBMap.method(:new)) if respond_to?(:add_conversion_proc)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def map_duckdb_type_to_sequel(duckdb_type)
|
|
53
|
+
duckdb_type.match?(DB_TYPE) ? :map : super
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def typecast_value_map(value)
|
|
57
|
+
raise Sequel::InvalidValue, "invalid value for map type: #{value.inspect}" unless value.is_a?(Hash) || value.is_a?(DuckDBMap)
|
|
58
|
+
|
|
59
|
+
DuckDBMap.new(value)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(hash)
|
|
64
|
+
super(hash.to_hash)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# MAP {'k': 1}. Each key and each value goes through the dataset's
|
|
68
|
+
# literal_append.
|
|
69
|
+
def sql_literal_append(ds, sql)
|
|
70
|
+
sql << "MAP {"
|
|
71
|
+
each_with_index do |(key, value), i|
|
|
72
|
+
sql << ", " unless i.zero?
|
|
73
|
+
ds.literal_append(sql, key)
|
|
74
|
+
sql << ": "
|
|
75
|
+
ds.literal_append(sql, value)
|
|
76
|
+
end
|
|
77
|
+
sql << "}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
module SQL
|
|
83
|
+
module Builders
|
|
84
|
+
# Wraps a Hash, so that Sequel writes it as a DuckDB map. The keys keep their
|
|
85
|
+
# type.
|
|
86
|
+
def duckdb_map(hash)
|
|
87
|
+
Sequel::DuckDB::DuckDBMap.new(hash)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Database.register_extension(:duckdb_map, DuckDB::DuckDBMap::DatabaseMethods)
|
|
93
|
+
end
|