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.
@@ -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 '#{@dst}' #{options_str}"
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
- else "#{key} #{value.to_s.upcase}"
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
- File.extname(@dst).delete_prefix(".")
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
@@ -11,6 +11,6 @@ module Sequel
11
11
  # puts Sequel::DuckDB::VERSION
12
12
  #
13
13
  # @since 0.1.0
14
- VERSION = "0.3.0"
14
+ VERSION = "0.4.0"
15
15
  end
16
16
  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