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
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The duckdb_ops extension adds DuckDB-specific expression builders to Sequel.
|
|
4
|
+
# It is opt-in: without it, Sequel generates the same SQL as before.
|
|
5
|
+
#
|
|
6
|
+
# Load it globally:
|
|
7
|
+
#
|
|
8
|
+
# Sequel.extension :duckdb_ops
|
|
9
|
+
#
|
|
10
|
+
# or through a Database (which also loads it globally):
|
|
11
|
+
#
|
|
12
|
+
# DB.extension :duckdb_ops
|
|
13
|
+
#
|
|
14
|
+
# Builders:
|
|
15
|
+
#
|
|
16
|
+
# Sequel.try_cast(:x, :DOUBLE) # TRY_CAST("x" AS DOUBLE)
|
|
17
|
+
# Sequel.try_cast(:x, Float) # TRY_CAST("x" AS double precision)
|
|
18
|
+
#
|
|
19
|
+
# Sequel.function(:f, :x, Sequel.duckdb_named(a: 1, b: true))
|
|
20
|
+
# # f("x", a = 1, b = TRUE)
|
|
21
|
+
#
|
|
22
|
+
# Sequel.duckdb_read_parquet("/d/**/*.parquet", hive_partitioning: true)
|
|
23
|
+
# # read_parquet(['/d/**/*.parquet'], hive_partitioning = TRUE)
|
|
24
|
+
#
|
|
25
|
+
# Sequel.duckdb_lambda(:x) { |x| x + 1 } # lambda "x": ("x" + 1)
|
|
26
|
+
# Sequel.duckdb_lambda(:x, Sequel[:x] + 1) # the same, body form
|
|
27
|
+
#
|
|
28
|
+
# TRY_CAST returns NULL when the cast fails, where CAST raises.
|
|
29
|
+
|
|
30
|
+
module Sequel
|
|
31
|
+
module DuckDB
|
|
32
|
+
# TRY_CAST(expr AS type). The type is handled exactly as in Sequel.cast:
|
|
33
|
+
# a Ruby class maps through the database's cast_type_literal, and a
|
|
34
|
+
# Symbol or String passes through unchanged.
|
|
35
|
+
class TryCast < Sequel::SQL::Cast
|
|
36
|
+
def to_s_append(ds, sql)
|
|
37
|
+
sql << "TRY_CAST("
|
|
38
|
+
ds.literal_append(sql, expr)
|
|
39
|
+
sql << " AS " << ds.db.cast_type_literal(type).to_s << ")"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Literalizes the values of DuckDB named parameters.
|
|
44
|
+
module OpsLiteral
|
|
45
|
+
# Array -> list literal [v1, v2]. Hash -> struct literal {'key': value}.
|
|
46
|
+
# Anything else goes through the dataset's literal_append.
|
|
47
|
+
#
|
|
48
|
+
# In a struct, a Symbol value is a bare type name and a String value is a
|
|
49
|
+
# string literal, so hive_types: { year: :BIGINT } gives {'year': BIGINT}
|
|
50
|
+
# and columns: { a: "INTEGER" } gives {'a': 'INTEGER'}. DuckDB 1.5 accepts
|
|
51
|
+
# both forms for hive_types and for read_json's columns (checked on a real
|
|
52
|
+
# database: {'year': BIGINT} and {'year': 'BIGINT'} both type the column).
|
|
53
|
+
def self.append(ds, sql, value)
|
|
54
|
+
case value
|
|
55
|
+
when Array
|
|
56
|
+
sql << "["
|
|
57
|
+
value.each_with_index do |v, i|
|
|
58
|
+
sql << ", " unless i.zero?
|
|
59
|
+
append(ds, sql, v)
|
|
60
|
+
end
|
|
61
|
+
sql << "]"
|
|
62
|
+
when Hash
|
|
63
|
+
sql << "{"
|
|
64
|
+
value.each_with_index do |(k, v), i|
|
|
65
|
+
sql << ", " unless i.zero?
|
|
66
|
+
ds.literal_append(sql, k.to_s)
|
|
67
|
+
sql << ": "
|
|
68
|
+
v.is_a?(Symbol) ? sql << v.to_s : append(ds, sql, v)
|
|
69
|
+
end
|
|
70
|
+
sql << "}"
|
|
71
|
+
else
|
|
72
|
+
ds.literal_append(sql, value)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A DuckDB list literal, ['a', 'b'].
|
|
78
|
+
class ListLiteral < Sequel::SQL::Expression
|
|
79
|
+
attr_reader :values
|
|
80
|
+
|
|
81
|
+
def initialize(values)
|
|
82
|
+
super()
|
|
83
|
+
@values = values.dup.freeze
|
|
84
|
+
freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_s_append(ds, sql)
|
|
88
|
+
OpsLiteral.append(ds, sql, values)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Named parameters, name1 = value1, name2 = value2, in the given order.
|
|
93
|
+
# Use it as the last argument of Sequel.function.
|
|
94
|
+
class NamedArguments < Sequel::SQL::Expression
|
|
95
|
+
NAME = /\A[a-z_][a-z0-9_]*\z/
|
|
96
|
+
|
|
97
|
+
attr_reader :args
|
|
98
|
+
|
|
99
|
+
def initialize(args)
|
|
100
|
+
super()
|
|
101
|
+
raise ArgumentError, "no DuckDB named parameters given" if args.empty?
|
|
102
|
+
|
|
103
|
+
args.each_key do |name|
|
|
104
|
+
raise ArgumentError, "invalid DuckDB parameter name: #{name.inspect}" unless NAME.match?(name.to_s)
|
|
105
|
+
end
|
|
106
|
+
@args = args.dup.freeze
|
|
107
|
+
freeze
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def to_s_append(ds, sql)
|
|
111
|
+
args.each_with_index do |(name, value), i|
|
|
112
|
+
sql << ", " unless i.zero?
|
|
113
|
+
sql << name.to_s << " = "
|
|
114
|
+
OpsLiteral.append(ds, sql, value)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A DuckDB lambda expression, lambda "acc", "x": body. The parameter names
|
|
120
|
+
# are quoted identifiers, so they match how Sequel writes the references in
|
|
121
|
+
# the body. It emits the lambda keyword syntax, never the deprecated arrow.
|
|
122
|
+
class Lambda < Sequel::SQL::Expression
|
|
123
|
+
attr_reader :params, :body
|
|
124
|
+
|
|
125
|
+
def initialize(params, body)
|
|
126
|
+
super()
|
|
127
|
+
raise ArgumentError, "a DuckDB lambda needs at least one parameter" if params.empty?
|
|
128
|
+
|
|
129
|
+
params.each do |name|
|
|
130
|
+
unless (name.is_a?(Symbol) || name.is_a?(String)) && !name.empty?
|
|
131
|
+
raise ArgumentError, "invalid DuckDB lambda parameter name: #{name.inspect}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
dup = params.map(&:to_s).tally.select { |_, n| n > 1 }.keys
|
|
135
|
+
raise ArgumentError, "duplicate DuckDB lambda parameter names: #{dup.inspect}" unless dup.empty?
|
|
136
|
+
|
|
137
|
+
@params = params.dup.freeze
|
|
138
|
+
@body = body
|
|
139
|
+
freeze
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def to_s_append(ds, sql)
|
|
143
|
+
sql << "lambda "
|
|
144
|
+
params.each_with_index do |name, i|
|
|
145
|
+
sql << ", " unless i.zero?
|
|
146
|
+
ds.quote_identifier_append(sql, name)
|
|
147
|
+
end
|
|
148
|
+
sql << ": "
|
|
149
|
+
ds.literal_append(sql, body)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Sequel.duckdb_lambda: the block form takes parameter names and yields
|
|
154
|
+
# one identifier per parameter; the body form takes a name or an Array of
|
|
155
|
+
# names, then the body.
|
|
156
|
+
def self.lambda_expression(args, block)
|
|
157
|
+
if block
|
|
158
|
+
if args.any? { |a| !(a.is_a?(Symbol) || a.is_a?(String)) }
|
|
159
|
+
raise ArgumentError, "Sequel.duckdb_lambda takes parameter names with a block, not a body argument: #{args.inspect}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
Lambda.new(args, block.call(*args.map { |name| Sequel::SQL::Identifier.new(name) }))
|
|
163
|
+
else
|
|
164
|
+
raise ArgumentError, "Sequel.duckdb_lambda without a block takes (params, body), got #{args.size} arguments" unless args.size == 2
|
|
165
|
+
|
|
166
|
+
Lambda.new(Array(args[0]), args[1])
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# name(['path', ...], named = value, ...) as a Sequel::SQL::Function.
|
|
171
|
+
def self.read_function(name, paths, named)
|
|
172
|
+
paths = Array(paths)
|
|
173
|
+
raise ArgumentError, "paths must be one or more Strings: #{paths.inspect}" if paths.empty? || !paths.all?(String)
|
|
174
|
+
|
|
175
|
+
args = [ ListLiteral.new(paths) ]
|
|
176
|
+
args << NamedArguments.new(named) unless named.empty?
|
|
177
|
+
Sequel::SQL::Function.new(name, *args)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
module SQL
|
|
182
|
+
module Builders
|
|
183
|
+
# Return a DuckDB TRY_CAST of expr to type.
|
|
184
|
+
def try_cast(expr, type)
|
|
185
|
+
Sequel::DuckDB::TryCast.new(expr, type)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Return DuckDB named parameters, for the last argument of Sequel.function.
|
|
189
|
+
def duckdb_named(**args)
|
|
190
|
+
Sequel::DuckDB::NamedArguments.new(args)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Return a DuckDB lambda expression, lambda "x": body. Two forms:
|
|
194
|
+
#
|
|
195
|
+
# Sequel.duckdb_lambda(:acc, :x) { |acc, x| acc + x }
|
|
196
|
+
# Sequel.duckdb_lambda([:acc, :x], Sequel[:acc] + :x)
|
|
197
|
+
def duckdb_lambda(*args, &block)
|
|
198
|
+
Sequel::DuckDB.lambda_expression(args, block)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# read_parquet(paths, named...). paths is a String or an Array of Strings.
|
|
202
|
+
def duckdb_read_parquet(paths, **named)
|
|
203
|
+
Sequel::DuckDB.read_function(:read_parquet, paths, named)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# read_csv(paths, named...). paths is a String or an Array of Strings.
|
|
207
|
+
def duckdb_read_csv(paths, **named)
|
|
208
|
+
Sequel::DuckDB.read_function(:read_csv, paths, named)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# read_json(paths, named...). paths is a String or an Array of Strings.
|
|
212
|
+
def duckdb_read_json(paths, **named)
|
|
213
|
+
Sequel::DuckDB.read_function(:read_json, paths, named)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
Database.register_extension(:duckdb_ops) { }
|
|
219
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The duckdb_struct extension writes Ruby hashes to DuckDB STRUCT columns and reads
|
|
4
|
+
# them back as Sequel::DuckDB::DuckDBStruct objects. Sequel's pg_row extension is the
|
|
5
|
+
# PostgreSQL counterpart. PostgreSQL row types are named and DuckDB structs are not,
|
|
6
|
+
# so no type registration is needed. It is opt-in: without it, the adapter's SQL and
|
|
7
|
+
# values do not change.
|
|
8
|
+
#
|
|
9
|
+
# Load the constructor globally:
|
|
10
|
+
#
|
|
11
|
+
# Sequel.extension :duckdb_struct
|
|
12
|
+
#
|
|
13
|
+
# Load it into a Database, which also converts results, schema types and model
|
|
14
|
+
# typecasting for that Database:
|
|
15
|
+
#
|
|
16
|
+
# DB.extension :duckdb_struct
|
|
17
|
+
#
|
|
18
|
+
# Write a struct:
|
|
19
|
+
#
|
|
20
|
+
# DB[:t].insert(s: Sequel.duckdb_struct(a: 1, b: "x")) # INSERT ... VALUES ({'a': 1, 'b': 'x'})
|
|
21
|
+
#
|
|
22
|
+
# A plain Ruby Hash still means a condition in Sequel. Only a DuckDBStruct is written
|
|
23
|
+
# as a DuckDB struct. A struct or list inside a struct must itself be a wrapper.
|
|
24
|
+
#
|
|
25
|
+
# With the extension loaded into a Database:
|
|
26
|
+
#
|
|
27
|
+
# * STRUCT result values are DuckDBStruct objects with Symbol keys. Only the
|
|
28
|
+
# top-level value is wrapped. A value nested in it stays as ruby-duckdb returns it.
|
|
29
|
+
# * db.schema reports a STRUCT column, for example STRUCT(a INTEGER), as :struct.
|
|
30
|
+
# * A model typecasts a Hash assigned to a STRUCT column to a DuckDBStruct.
|
|
31
|
+
|
|
32
|
+
require "delegate"
|
|
33
|
+
|
|
34
|
+
module Sequel
|
|
35
|
+
module DuckDB
|
|
36
|
+
# A Hash with Symbol keys that Sequel writes as a DuckDB struct literal.
|
|
37
|
+
class DuckDBStruct < DelegateClass(Hash)
|
|
38
|
+
# A STRUCT db_type. STRUCT(a INTEGER)[] is a list, so it does not match.
|
|
39
|
+
DB_TYPE = /\ASTRUCT\(.*\)\z/im
|
|
40
|
+
|
|
41
|
+
module DatabaseMethods
|
|
42
|
+
def self.extended(db)
|
|
43
|
+
db.instance_exec do
|
|
44
|
+
@schema_type_classes[:struct] = DuckDBStruct
|
|
45
|
+
add_conversion_proc(:struct, DuckDBStruct.method(:new)) if respond_to?(:add_conversion_proc)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def map_duckdb_type_to_sequel(duckdb_type)
|
|
52
|
+
duckdb_type.match?(DB_TYPE) ? :struct : super
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def typecast_value_struct(value)
|
|
56
|
+
raise Sequel::InvalidValue, "invalid value for struct type: #{value.inspect}" unless value.is_a?(Hash) || value.is_a?(DuckDBStruct)
|
|
57
|
+
|
|
58
|
+
DuckDBStruct.new(value)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize(hash)
|
|
63
|
+
super(hash.to_hash.transform_keys(&:to_sym))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# {'a': 1, 'b': 'x'}. Each key is a string literal, and each value goes through
|
|
67
|
+
# the dataset's literal_append.
|
|
68
|
+
def sql_literal_append(ds, sql)
|
|
69
|
+
sql << "{"
|
|
70
|
+
each_with_index do |(key, value), i|
|
|
71
|
+
sql << ", " unless i.zero?
|
|
72
|
+
ds.literal_append(sql, key.to_s)
|
|
73
|
+
sql << ": "
|
|
74
|
+
ds.literal_append(sql, value)
|
|
75
|
+
end
|
|
76
|
+
sql << "}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
module SQL
|
|
82
|
+
module Builders
|
|
83
|
+
# Wraps a Hash, so that Sequel writes it as a DuckDB struct. The keys become
|
|
84
|
+
# Symbols.
|
|
85
|
+
def duckdb_struct(hash)
|
|
86
|
+
Sequel::DuckDB::DuckDBStruct.new(hash)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
Database.register_extension(:duckdb_struct, DuckDB::DuckDBStruct::DatabaseMethods)
|
|
92
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sequel-duckdb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ryan Duryea
|
|
@@ -99,6 +99,20 @@ dependencies:
|
|
|
99
99
|
- - "~>"
|
|
100
100
|
- !ruby/object:Gem::Version
|
|
101
101
|
version: '1.21'
|
|
102
|
+
- !ruby/object:Gem::Dependency
|
|
103
|
+
name: rubocop-rails-omakase
|
|
104
|
+
requirement: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - "~>"
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '1.1'
|
|
109
|
+
type: :development
|
|
110
|
+
prerelease: false
|
|
111
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - "~>"
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '1.1'
|
|
102
116
|
- !ruby/object:Gem::Dependency
|
|
103
117
|
name: rubocop-minitest
|
|
104
118
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -172,14 +186,22 @@ files:
|
|
|
172
186
|
- lib/sequel/duckdb.rb
|
|
173
187
|
- lib/sequel/duckdb/helpers/copier.rb
|
|
174
188
|
- lib/sequel/duckdb/helpers/pathifier.rb
|
|
189
|
+
- lib/sequel/duckdb/instance_registry.rb
|
|
190
|
+
- lib/sequel/duckdb/sql_time_tz.rb
|
|
175
191
|
- lib/sequel/duckdb/version.rb
|
|
176
|
-
|
|
192
|
+
- lib/sequel/extensions/duckdb_array.rb
|
|
193
|
+
- lib/sequel/extensions/duckdb_json.rb
|
|
194
|
+
- lib/sequel/extensions/duckdb_map.rb
|
|
195
|
+
- lib/sequel/extensions/duckdb_ops.rb
|
|
196
|
+
- lib/sequel/extensions/duckdb_struct.rb
|
|
197
|
+
homepage: https://github.com/outcomesinsights/sequel-duckdb
|
|
177
198
|
licenses:
|
|
178
199
|
- MIT
|
|
179
200
|
metadata:
|
|
180
201
|
allowed_push_host: https://rubygems.org
|
|
181
|
-
homepage_uri: https://github.com/
|
|
182
|
-
|
|
202
|
+
homepage_uri: https://github.com/outcomesinsights/sequel-duckdb
|
|
203
|
+
source_code_uri: https://github.com/outcomesinsights/sequel-duckdb
|
|
204
|
+
changelog_uri: https://github.com/outcomesinsights/sequel-duckdb/blob/main/CHANGELOG.md
|
|
183
205
|
rubygems_mfa_required: 'true'
|
|
184
206
|
rdoc_options: []
|
|
185
207
|
require_paths:
|