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.
@@ -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.3.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
- homepage: https://github.com/sequel/sequel-duckdb
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/sequel/sequel-duckdb
182
- changelog_uri: https://github.com/sequel/sequel-duckdb/blob/main/CHANGELOG.md
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: