duckdb 1.4.2.0 → 1.4.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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.github/copilot-instructions.md +169 -0
  3. data/.github/workflows/linter.yml +30 -0
  4. data/.github/workflows/test_on_macos.yml +7 -2
  5. data/.github/workflows/test_on_ubuntu.yml +7 -2
  6. data/.github/workflows/test_on_windows.yml +8 -3
  7. data/.rubocop.yml +34 -0
  8. data/CHANGELOG.md +74 -0
  9. data/Dockerfile +2 -2
  10. data/Gemfile +12 -0
  11. data/Gemfile.lock +70 -7
  12. data/Rakefile +8 -6
  13. data/bin/console +4 -3
  14. data/duckdb.gemspec +2 -6
  15. data/ext/duckdb/bind_info.c +243 -0
  16. data/ext/duckdb/bind_info.h +13 -0
  17. data/ext/duckdb/connection.c +60 -2
  18. data/ext/duckdb/connection.h +1 -0
  19. data/ext/duckdb/data_chunk.c +137 -0
  20. data/ext/duckdb/data_chunk.h +13 -0
  21. data/ext/duckdb/duckdb.c +7 -0
  22. data/ext/duckdb/function_info.c +65 -0
  23. data/ext/duckdb/function_info.h +13 -0
  24. data/ext/duckdb/init_info.c +65 -0
  25. data/ext/duckdb/init_info.h +13 -0
  26. data/ext/duckdb/logical_type.c +42 -0
  27. data/ext/duckdb/memory_helper.c +339 -0
  28. data/ext/duckdb/memory_helper.h +8 -0
  29. data/ext/duckdb/result.c +8 -9
  30. data/ext/duckdb/result.h +1 -0
  31. data/ext/duckdb/ruby-duckdb.h +7 -0
  32. data/ext/duckdb/scalar_function.c +336 -1
  33. data/ext/duckdb/scalar_function.h +2 -0
  34. data/ext/duckdb/table_function.c +403 -0
  35. data/ext/duckdb/table_function.h +16 -0
  36. data/ext/duckdb/vector.c +201 -0
  37. data/ext/duckdb/vector.h +13 -0
  38. data/lib/duckdb/appender.rb +21 -11
  39. data/lib/duckdb/bind_info.rb +32 -0
  40. data/lib/duckdb/casting.rb +35 -0
  41. data/lib/duckdb/connection.rb +123 -2
  42. data/lib/duckdb/converter.rb +32 -47
  43. data/lib/duckdb/data_chunk.rb +114 -0
  44. data/lib/duckdb/extracted_statements.rb +1 -1
  45. data/lib/duckdb/function_info.rb +21 -0
  46. data/lib/duckdb/init_info.rb +22 -0
  47. data/lib/duckdb/instance_cache.rb +0 -4
  48. data/lib/duckdb/interval.rb +1 -1
  49. data/lib/duckdb/logical_type.rb +46 -5
  50. data/lib/duckdb/prepared_statement.rb +22 -8
  51. data/lib/duckdb/result.rb +1 -0
  52. data/lib/duckdb/scalar_function.rb +119 -0
  53. data/lib/duckdb/table_function.rb +191 -0
  54. data/lib/duckdb/vector.rb +20 -0
  55. data/lib/duckdb/version.rb +1 -1
  56. data/lib/duckdb.rb +8 -0
  57. data/sample/async_query.rb +2 -0
  58. data/sample/async_query_stream.rb +2 -0
  59. data/sample/issue922.rb +54 -0
  60. data/sample/issue922_benchmark.rb +169 -0
  61. data/sample/issue930.rb +49 -0
  62. data/sample/issue930_benchmark.rb +70 -0
  63. metadata +30 -57
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DuckDB
4
+ # DuckDB::ScalarFunction encapsulates DuckDB's scalar function
5
+ #
6
+ # @note DuckDB::ScalarFunction is experimental.
7
+ # @note DuckDB::ScalarFunction must be used with threads=1 in DuckDB.
8
+ # Set this with: connection.execute('SET threads=1')
9
+ class ScalarFunction
10
+ # Create and configure a scalar function in one call
11
+ #
12
+ # @param name [String, Symbol] the function name
13
+ # @param return_type [DuckDB::LogicalType] the return type
14
+ # @param parameter_type [DuckDB::LogicalType, nil] single parameter type (use this OR parameter_types)
15
+ # @param parameter_types [Array<DuckDB::LogicalType>, nil] multiple parameter types
16
+ # @yield [*args] the function implementation
17
+ # @return [DuckDB::ScalarFunction] configured scalar function ready to register
18
+ # @raise [ArgumentError] if block is not provided or both parameter_type and parameter_types are specified
19
+ #
20
+ # @example Single parameter function
21
+ # sf = DuckDB::ScalarFunction.create(
22
+ # name: :triple,
23
+ # return_type: DuckDB::LogicalType::INTEGER,
24
+ # parameter_type: DuckDB::LogicalType::INTEGER
25
+ # ) { |v| v * 3 }
26
+ #
27
+ # @example Multiple parameters
28
+ # sf = DuckDB::ScalarFunction.create(
29
+ # name: :add,
30
+ # return_type: DuckDB::LogicalType::INTEGER,
31
+ # parameter_types: [DuckDB::LogicalType::INTEGER, DuckDB::LogicalType::INTEGER]
32
+ # ) { |a, b| a + b }
33
+ #
34
+ # @example No parameters (constant function)
35
+ # sf = DuckDB::ScalarFunction.create(
36
+ # name: :random_num,
37
+ # return_type: DuckDB::LogicalType::INTEGER
38
+ # ) { rand(100) }
39
+ def self.create(name:, return_type:, parameter_type: nil, parameter_types: nil, &) # rubocop:disable Metrics/MethodLength
40
+ raise ArgumentError, 'Block required' unless block_given?
41
+ raise ArgumentError, 'Cannot specify both parameter_type and parameter_types' if parameter_type && parameter_types
42
+
43
+ params = if parameter_type
44
+ [parameter_type]
45
+ elsif parameter_types
46
+ parameter_types
47
+ else
48
+ []
49
+ end
50
+
51
+ sf = new
52
+ sf.name = name.to_s
53
+ sf.return_type = return_type
54
+ params.each { |type| sf.add_parameter(type) }
55
+ sf.set_function(&)
56
+ sf
57
+ end
58
+
59
+ # Supported types for scalar function parameters and return values
60
+ SUPPORTED_TYPES = %i[
61
+ bigint
62
+ blob
63
+ boolean
64
+ date
65
+ double
66
+ float
67
+ integer
68
+ smallint
69
+ time
70
+ timestamp
71
+ tinyint
72
+ ubigint
73
+ uinteger
74
+ usmallint
75
+ utinyint
76
+ varchar
77
+ ].freeze
78
+
79
+ private_constant :SUPPORTED_TYPES
80
+
81
+ # Adds a parameter to the scalar function.
82
+ # Currently supports BIGINT, BLOB, BOOLEAN, DATE, DOUBLE, FLOAT, INTEGER, SMALLINT, TIME, TIMESTAMP, TINYINT,
83
+ # UBIGINT, UINTEGER, USMALLINT, UTINYINT, and VARCHAR types.
84
+ #
85
+ # @param logical_type [DuckDB::LogicalType] the parameter type
86
+ # @return [DuckDB::ScalarFunction] self
87
+ # @raise [DuckDB::Error] if the type is not supported
88
+ def add_parameter(logical_type)
89
+ raise DuckDB::Error, 'logical_type must be a DuckDB::LogicalType' unless logical_type.is_a?(DuckDB::LogicalType)
90
+
91
+ unless SUPPORTED_TYPES.include?(logical_type.type)
92
+ type_list = SUPPORTED_TYPES.map(&:upcase).join(', ')
93
+ raise DuckDB::Error,
94
+ "Only #{type_list} parameter types are currently supported"
95
+ end
96
+
97
+ _add_parameter(logical_type)
98
+ end
99
+
100
+ # Sets the return type for the scalar function.
101
+ # Currently supports BIGINT, BLOB, BOOLEAN, DATE, DOUBLE, FLOAT, INTEGER, SMALLINT, TIME, TIMESTAMP, TINYINT,
102
+ # UBIGINT, UINTEGER, USMALLINT, UTINYINT, and VARCHAR types.
103
+ #
104
+ # @param logical_type [DuckDB::LogicalType] the return type
105
+ # @return [DuckDB::ScalarFunction] self
106
+ # @raise [DuckDB::Error] if the type is not supported
107
+ def return_type=(logical_type)
108
+ raise DuckDB::Error, 'logical_type must be a DuckDB::LogicalType' unless logical_type.is_a?(DuckDB::LogicalType)
109
+
110
+ unless SUPPORTED_TYPES.include?(logical_type.type)
111
+ type_list = SUPPORTED_TYPES.map(&:upcase).join(', ')
112
+ raise DuckDB::Error,
113
+ "Only #{type_list} return types are currently supported"
114
+ end
115
+
116
+ _set_return_type(logical_type)
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DuckDB
4
+ #
5
+ # The DuckDB::TableFunction encapsulates a DuckDB table function.
6
+ #
7
+ # NOTE: DuckDB::TableFunction is experimental now.
8
+ #
9
+ # require 'duckdb'
10
+ #
11
+ # db = DuckDB::Database.new
12
+ # conn = db.connect
13
+ #
14
+ # # Low-level API:
15
+ # tf = DuckDB::TableFunction.new
16
+ # tf.name = 'my_function'
17
+ # tf.add_parameter(DuckDB::LogicalType::BIGINT)
18
+ #
19
+ # tf.bind do |bind_info|
20
+ # bind_info.add_result_column('value', DuckDB::LogicalType::BIGINT)
21
+ # end
22
+ #
23
+ # tf.execute do |func_info, output|
24
+ # # Fill output data...
25
+ # 0 # Return 0 to signal done
26
+ # end
27
+ #
28
+ # conn.register_table_function(tf)
29
+ #
30
+ # # High-level API (recommended):
31
+ # tf = DuckDB::TableFunction.create(
32
+ # name: 'my_function',
33
+ # parameters: [DuckDB::LogicalType::BIGINT],
34
+ # columns: { 'value' => DuckDB::LogicalType::BIGINT }
35
+ # ) do |func_info, output|
36
+ # # Fill output data...
37
+ # 0 # Return row count (0 when done)
38
+ # end
39
+ #
40
+ class TableFunction
41
+ # TableFunction#initialize is defined in C extension
42
+
43
+ @table_adapters = {}
44
+
45
+ class << self
46
+ #
47
+ # Creates a new table function with a declarative API.
48
+ #
49
+ # @param name [String] The name of the table function
50
+ # @param parameters [Array<LogicalType>, Hash<String, LogicalType>] Function parameters (optional)
51
+ # @param columns [Hash<String, LogicalType>] Output columns (required)
52
+ # @yield [func_info, output] The execute block that generates data
53
+ # @yieldparam func_info [FunctionInfo] Function execution context
54
+ # @yieldparam output [DataChunk] Output data chunk to fill
55
+ # @yieldreturn [Integer] Number of rows generated (0 when done)
56
+ # @return [TableFunction] The configured table function
57
+ #
58
+ # @example Simple range function
59
+ # tf = TableFunction.create(
60
+ # name: 'my_range',
61
+ # parameters: [LogicalType::BIGINT],
62
+ # columns: { 'value' => LogicalType::BIGINT }
63
+ # ) do |func_info, output|
64
+ # # Generate data...
65
+ # 0 # Signal done
66
+ # end
67
+ #
68
+ # @example Function that returns data
69
+ # tf = TableFunction.create(
70
+ # name: 'my_function',
71
+ # columns: { 'value' => LogicalType::BIGINT }
72
+ # ) do |func_info, output|
73
+ # vec = output.get_vector(0)
74
+ # # Fill vector...
75
+ # 3 # Return row count
76
+ # end
77
+ #
78
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
79
+ def create(name:, columns:, parameters: nil, &)
80
+ raise ArgumentError, 'name is required' unless name
81
+ raise ArgumentError, 'columns are required' unless columns
82
+ raise ArgumentError, 'block is required' unless block_given?
83
+
84
+ tf = new
85
+ tf.name = name
86
+
87
+ # Add parameters (positional or named)
88
+ if parameters
89
+ case parameters
90
+ when Array
91
+ parameters.each { |type| tf.add_parameter(type) }
92
+ when Hash
93
+ parameters.each { |param_name, type| tf.add_named_parameter(param_name, type) }
94
+ else
95
+ raise ArgumentError, 'parameters must be Array or Hash'
96
+ end
97
+ end
98
+
99
+ # Set bind callback to add result columns
100
+ tf.bind do |bind_info|
101
+ columns.each do |col_name, col_type|
102
+ bind_info.add_result_column(col_name, col_type)
103
+ end
104
+ end
105
+
106
+ # Set init callback (required by DuckDB)
107
+ tf.init do |_init_info|
108
+ # No-op
109
+ end
110
+
111
+ # Set execute callback - user's block returns row count
112
+ tf.execute do |func_info, output|
113
+ size = yield(func_info, output)
114
+ output.size = Integer(size)
115
+ end
116
+
117
+ tf
118
+ end
119
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
120
+
121
+ # Registers a table adapter for a Ruby class.
122
+ #
123
+ # The adapter is used by +DuckDB::Connection#expose_as_table+ to convert
124
+ # instances of +klass+ into a DuckDB table function. The adapter must respond
125
+ # to +call(object, name, columns: nil)+ and return a +DuckDB::TableFunction+.
126
+ #
127
+ # == Implementing a Table Adapter
128
+ #
129
+ # An adapter is any object that responds to +call(object, name, columns: nil)+.
130
+ # The +columns:+ keyword argument allows callers to override the column schema;
131
+ # the adapter should fall back to its own schema detection when it is +nil+.
132
+ #
133
+ # The execute block passed to +DuckDB::TableFunction.create+ must:
134
+ # - Write one batch of rows into +output+ per call
135
+ # - Return the number of rows written as an +Integer+
136
+ # - Return +0+ to signal that all data has been exhausted
137
+ #
138
+ # @example Minimal adapter for CSV objects
139
+ # class CSVTableAdapter
140
+ # def call(csv, name, columns: nil)
141
+ # columns ||= infer_columns(csv)
142
+ #
143
+ # DuckDB::TableFunction.create(name:, columns:) do |_func_info, output|
144
+ # row = csv.readline
145
+ # if row
146
+ # row.each_with_index { |cell, i| output.set_value(i, 0, cell[1]) }
147
+ # 1 # wrote one row
148
+ # else
149
+ # csv.rewind
150
+ # 0 # signal end of data
151
+ # end
152
+ # end
153
+ # end
154
+ #
155
+ # private
156
+ #
157
+ # def infer_columns(csv)
158
+ # headers = csv.first.headers
159
+ # csv.rewind
160
+ # headers.each_with_object({}) { |h, hsh| hsh[h] = DuckDB::LogicalType::VARCHAR }
161
+ # end
162
+ # end
163
+ #
164
+ # # Register and use:
165
+ # DuckDB::TableFunction.add_table_adapter(CSV, CSVTableAdapter.new)
166
+ # con.execute('SET threads=1')
167
+ # con.expose_as_table(csv, 'csv_table')
168
+ # con.query('SELECT * FROM csv_table()').to_a
169
+ #
170
+ # @param klass [Class] the Ruby class to register an adapter for (e.g. +CSV+)
171
+ # @param adapter [#call] the adapter object
172
+ # @return [void]
173
+ #
174
+ def add_table_adapter(klass, adapter)
175
+ @table_adapters[klass] = adapter
176
+ end
177
+
178
+ # Returns the table adapter registered for the given class, or +nil+ if none.
179
+ #
180
+ # @param klass [Class] the Ruby class to look up
181
+ # @return [#call, nil] the registered adapter, or +nil+ if not found
182
+ #
183
+ # @example
184
+ # adapter = DuckDB::TableFunction.table_adapter_for(CSV)
185
+ #
186
+ def table_adapter_for(klass)
187
+ @table_adapters[klass]
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DuckDB
4
+ #
5
+ # The DuckDB::Vector represents a column vector in a data chunk.
6
+ #
7
+ # Vectors store the actual data for table function output.
8
+ #
9
+ # Example:
10
+ #
11
+ # vector = output.get_vector(0)
12
+ # vector.assign_string_element(0, 'hello')
13
+ # vector.assign_string_element(1, 'world')
14
+ #
15
+ # rubocop:disable Lint/EmptyClass
16
+ class Vector
17
+ # All methods are defined in C extension (ext/duckdb/vector.c)
18
+ end
19
+ # rubocop:enable Lint/EmptyClass
20
+ end
@@ -3,5 +3,5 @@
3
3
  module DuckDB
4
4
  # The version string of ruby-duckdb.
5
5
  # Currently, ruby-duckdb is NOT semantic versioning.
6
- VERSION = '1.4.2.0'
6
+ VERSION = '1.4.4.0'
7
7
  end
data/lib/duckdb.rb CHANGED
@@ -14,8 +14,16 @@ require 'duckdb/appender'
14
14
  require 'duckdb/config'
15
15
  require 'duckdb/column'
16
16
  require 'duckdb/logical_type'
17
+ require 'duckdb/scalar_function'
18
+ require 'duckdb/bind_info'
19
+ require 'duckdb/init_info'
20
+ require 'duckdb/function_info'
21
+ require 'duckdb/vector'
22
+ require 'duckdb/data_chunk'
23
+ require 'duckdb/table_function'
17
24
  require 'duckdb/infinity'
18
25
  require 'duckdb/instance_cache'
26
+ require 'duckdb/casting'
19
27
 
20
28
  # DuckDB provides Ruby interface of DuckDB.
21
29
  module DuckDB
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'duckdb'
2
4
 
3
5
  DuckDB::Database.open do |db|
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'duckdb'
2
4
 
3
5
  DuckDB::Database.open do |db|
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'duckdb'
4
+ require 'polars-df'
5
+
6
+ class PolarsDataFrameTableAdapter
7
+ def call(data_frame, name, columns: nil)
8
+ columns ||= infer_columns(data_frame)
9
+ DuckDB::TableFunction.create(name:, columns:, &execute_block(data_frame))
10
+ end
11
+
12
+ private
13
+
14
+ def execute_block(data_frame)
15
+ counter = 0
16
+ height = data_frame.height
17
+ width = data_frame.width
18
+ proc do |_func_info, output|
19
+ next counter = 0 if counter >= height
20
+
21
+ write_row(data_frame, output, counter, width)
22
+ counter += 1
23
+ 1
24
+ end
25
+ end
26
+
27
+ def write_row(data_frame, output, counter, width)
28
+ width.times { |index| output.set_value(index, 0, data_frame[counter, index]) }
29
+ end
30
+
31
+ def infer_columns(data_frame)
32
+ data_frame.columns.to_h { |header| [header, DuckDB::LogicalType::VARCHAR] }
33
+ end
34
+ end
35
+
36
+ DuckDB::TableFunction.add_table_adapter(Polars::DataFrame, PolarsDataFrameTableAdapter.new)
37
+
38
+ df = Polars::DataFrame.new(
39
+ {
40
+ a: [1, 2, 3],
41
+ b: %w[one two three]
42
+ }
43
+ )
44
+
45
+ db = DuckDB::Database.open
46
+ con = db.connect
47
+ con.query('SET threads=1')
48
+ con.expose_as_table(df, 'polars_df')
49
+ result = con.query('SELECT * FROM polars_df()').to_a
50
+ p result
51
+ puts result.first.first == '1'
52
+
53
+ con.close
54
+ db.close
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/OneClassPerFile
4
+ require 'duckdb'
5
+ require 'polars-df'
6
+ require 'tmpdir'
7
+
8
+ class PolarsDataFrameTableAdapter
9
+ def call(data_frame, name, columns: nil)
10
+ columns ||= infer_columns(data_frame)
11
+ DuckDB::TableFunction.create(name:, columns:, &execute_block(data_frame))
12
+ end
13
+
14
+ private
15
+
16
+ def execute_block(data_frame)
17
+ counter = 0
18
+ height = data_frame.height
19
+ width = data_frame.width
20
+ proc do |_func_info, output|
21
+ next counter = 0 if counter >= height
22
+
23
+ write_row(data_frame, output, counter, width)
24
+ counter += 1
25
+ 1
26
+ end
27
+ end
28
+
29
+ def write_row(data_frame, output, counter, width)
30
+ width.times { |index| output.set_value(index, 0, data_frame[counter, index]) }
31
+ end
32
+
33
+ def infer_columns(data_frame)
34
+ data_frame.columns.to_h { |header| [header, DuckDB::LogicalType::VARCHAR] }
35
+ end
36
+ end
37
+
38
+ # Batch approach: write BATCH_SIZE rows per execute call to reduce Ruby<->C crossings
39
+ class PolarsDataFrameBatchTableAdapter
40
+ BATCH_SIZE = 2048
41
+
42
+ def call(data_frame, name, columns: nil)
43
+ columns ||= infer_columns(data_frame)
44
+ DuckDB::TableFunction.create(name:, columns:, &execute_block(data_frame))
45
+ end
46
+
47
+ private
48
+
49
+ def execute_block(data_frame)
50
+ offset = 0
51
+ height = data_frame.height
52
+ width = data_frame.width
53
+ proc do |_func_info, output|
54
+ next offset = 0 if offset >= height
55
+
56
+ rows = [height - offset, BATCH_SIZE].min
57
+ write_batch(data_frame, output, offset, rows, width)
58
+ offset += rows
59
+ rows
60
+ end
61
+ end
62
+
63
+ def write_batch(data_frame, output, offset, rows, width)
64
+ rows.times do |row_idx|
65
+ width.times { |col_idx| output.set_value(col_idx, row_idx, data_frame[offset + row_idx, col_idx]) }
66
+ end
67
+ end
68
+
69
+ def infer_columns(data_frame)
70
+ data_frame.columns.to_h { |header| [header, DuckDB::LogicalType::VARCHAR] }
71
+ end
72
+ end
73
+
74
+ # Optimized batch approach: pre-extract columns as Ruby arrays to avoid
75
+ # repeated Polars FFI calls, and use assign_string_element to skip type dispatch
76
+ class PolarsDataFrameOptimizedTableAdapter
77
+ BATCH_SIZE = 2048
78
+
79
+ def call(data_frame, name, columns: nil)
80
+ columns ||= infer_columns(data_frame)
81
+ DuckDB::TableFunction.create(name:, columns:, &execute_block(data_frame))
82
+ end
83
+
84
+ private
85
+
86
+ # rubocop:disable Metrics/MethodLength
87
+ def execute_block(data_frame)
88
+ col_arrays = extract_columns(data_frame)
89
+ offset = 0
90
+ height = data_frame.height
91
+ width = data_frame.width
92
+ proc do |_func_info, output|
93
+ next offset = 0 if offset >= height
94
+
95
+ rows = [height - offset, BATCH_SIZE].min
96
+ vectors = width.times.map { |i| output.get_vector(i) }
97
+ write_batch(col_arrays, vectors, offset, rows)
98
+ offset += rows
99
+ rows
100
+ end
101
+ end
102
+ # rubocop:enable Metrics/MethodLength
103
+
104
+ def extract_columns(data_frame)
105
+ data_frame.columns.map { |col| data_frame[col].cast(Polars::Utf8).to_a }
106
+ end
107
+
108
+ def write_batch(col_arrays, vectors, offset, rows)
109
+ col_arrays.each_with_index do |col_data, col_idx|
110
+ vec = vectors[col_idx]
111
+ rows.times { |row_idx| vec.assign_string_element(row_idx, col_data[offset + row_idx].to_s) }
112
+ end
113
+ end
114
+
115
+ def infer_columns(data_frame)
116
+ data_frame.columns.to_h { |header| [header, DuckDB::LogicalType::VARCHAR] }
117
+ end
118
+ end
119
+
120
+ def query_via_parquet(con, data_frame, name, parquet_path)
121
+ data_frame.write_parquet(parquet_path)
122
+ con.query("CREATE OR REPLACE TABLE #{name} AS SELECT * FROM read_parquet('#{parquet_path}')")
123
+ con.query("SELECT * FROM #{name}").to_a
124
+ end
125
+
126
+ df = Polars::DataFrame.new(
127
+ {
128
+ id: 100_000.times.map { |i| i + 1 },
129
+ name: 100_000.times.map { |i| "Name#{i + 1}" },
130
+ age: 100_000.times.map { rand(0..100) }
131
+ }
132
+ )
133
+
134
+ db = DuckDB::Database.open
135
+ con = db.connect
136
+ con.query('SET threads=1')
137
+
138
+ DuckDB::TableFunction.add_table_adapter(Polars::DataFrame, PolarsDataFrameTableAdapter.new)
139
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
140
+ con.expose_as_table(df, 'polars_tf')
141
+ con.query('SELECT * FROM polars_tf()').to_a
142
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
143
+
144
+ DuckDB::TableFunction.add_table_adapter(Polars::DataFrame, PolarsDataFrameBatchTableAdapter.new)
145
+ start_time3 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
146
+ con.expose_as_table(df, 'polars_tf_batch')
147
+ con.query('SELECT * FROM polars_tf_batch()').to_a
148
+ end_time3 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
149
+
150
+ DuckDB::TableFunction.add_table_adapter(Polars::DataFrame, PolarsDataFrameOptimizedTableAdapter.new)
151
+ start_time4 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
152
+ con.expose_as_table(df, 'polars_tf_opt')
153
+ con.query('SELECT * FROM polars_tf_opt()').to_a
154
+ end_time4 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
155
+
156
+ parquet_path = File.join(Dir.tmpdir, 'issue922_benchmark.parquet')
157
+ start_time2 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
158
+ query_via_parquet(con, df, 'polars_pq', parquet_path)
159
+ end_time2 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
160
+
161
+ con.close
162
+ db.close
163
+ File.delete(parquet_path)
164
+
165
+ puts "Time taken for table function approach (1 row/call): #{end_time - start_time} seconds"
166
+ puts "Time taken for table function approach (batch/call): #{end_time3 - start_time3} seconds"
167
+ puts "Time taken for table function approach (batch + pre-extract): #{end_time4 - start_time4} seconds"
168
+ puts "Time taken for parquet file approach: #{end_time2 - start_time2} seconds"
169
+ # rubocop:enable Style/OneClassPerFile
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'duckdb'
4
+ require 'csv'
5
+ require 'stringio'
6
+
7
+ class CSVTableAdapter
8
+ def call(csv, name, columns: nil)
9
+ columns ||= infer_columns(csv)
10
+ DuckDB::TableFunction.create(name:, columns:) do |_func_info, output|
11
+ write_row(csv, output)
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def write_row(csv, output)
18
+ line = csv.readline
19
+ if line
20
+ line.each_with_index { |cell, index| output.set_value(index, 0, cell[1]) }
21
+ 1
22
+ else
23
+ csv.rewind
24
+ 0
25
+ end
26
+ end
27
+
28
+ def infer_columns(csv)
29
+ headers = csv.first.headers
30
+ csv.rewind
31
+ headers.to_h { |header| [header, DuckDB::LogicalType::VARCHAR] }
32
+ end
33
+ end
34
+
35
+ DuckDB::TableFunction.add_table_adapter(CSV, CSVTableAdapter.new)
36
+
37
+ csv_data = "id,name,age\n1,Alice,30\n2,Bob,25\n3,Charlie,35"
38
+ csv_data += 30_000.times.map { |i| "\n#{i + 4},Name#{i + 4},#{rand(0..100)}" }.join
39
+ csv = CSV.new(StringIO.new(csv_data), headers: true)
40
+
41
+ db = DuckDB::Database.open
42
+ con = db.connect
43
+ con.query('SET threads=1')
44
+ con.expose_as_table(csv, 'csv_table')
45
+ result = con.query('SELECT * FROM csv_table()').to_a
46
+
47
+ p result
48
+ puts result.first.first == '1'
49
+ puts result.first[1] == 'Alice'