sequel-duckdb 0.1.0 → 0.2.1
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/.beads/.beads-credential-key +1 -0
- data/.beads/.gitignore +66 -0
- data/.beads/README.md +85 -0
- data/.beads/config.yaml +56 -0
- data/.beads/hooks/post-checkout +24 -0
- data/.beads/hooks/post-merge +24 -0
- data/.beads/hooks/pre-commit +24 -0
- data/.beads/hooks/pre-push +24 -0
- data/.beads/hooks/prepare-commit-msg +24 -0
- data/.beads/metadata.json +7 -0
- data/.kiro/specs/advanced-sql-features-implementation/design.md +3 -1
- data/.kiro/specs/advanced-sql-features-implementation/requirements.md +1 -1
- data/.kiro/specs/advanced-sql-features-implementation/tasks.md +5 -1
- data/.kiro/specs/duckdb-sql-syntax-compatibility/design.md +15 -1
- data/.kiro/specs/duckdb-sql-syntax-compatibility/requirements.md +1 -1
- data/.kiro/specs/duckdb-sql-syntax-compatibility/tasks.md +13 -0
- data/.kiro/specs/edge-cases-and-validation-fixes/requirements.md +1 -1
- data/.kiro/specs/integration-test-database-setup/requirements.md +1 -1
- data/.kiro/specs/sequel-duckdb-adapter/design.md +8 -1
- data/.kiro/specs/sequel-duckdb-adapter/requirements.md +10 -10
- data/.kiro/specs/sequel-duckdb-adapter/tasks.md +48 -3
- data/.kiro/specs/sql-expression-handling-fix/design.md +34 -1
- data/.kiro/specs/sql-expression-handling-fix/requirements.md +1 -1
- data/.kiro/specs/sql-expression-handling-fix/tasks.md +3 -0
- data/.kiro/specs/test-infrastructure-improvements/requirements.md +1 -1
- data/.kiro/steering/product.md +5 -1
- data/.kiro/steering/structure.md +1 -1
- data/.kiro/steering/tech.md +14 -1
- data/.kiro/steering/testing.md +22 -1
- data/.mdformat.toml +2 -0
- data/.rubocop.yml +116 -58
- data/.rubocop_todo.yml +323 -0
- data/AGENTS.md +180 -0
- data/API_DOCUMENTATION.md +73 -49
- data/CHANGELOG.md +47 -10
- data/FINAL_STATUS.md +99 -0
- data/LICENSE +1 -1
- data/MIGRATION_EXAMPLES.md +1 -1
- data/PERFORMANCE_OPTIMIZATIONS.md +4 -1
- data/README.md +90 -1
- data/REFACTORING_SUMMARY.md +264 -0
- data/Rakefile +21 -5
- data/TASK_10.2_IMPLEMENTATION_SUMMARY.md +19 -1
- data/docs/DUCKDB_SQL_PATTERNS.md +39 -1
- data/docs/TASK_12_VERIFICATION_SUMMARY.md +14 -1
- data/justfile +50 -0
- data/lib/sequel/adapters/duckdb.rb +137 -108
- data/lib/sequel/adapters/shared/duckdb.rb +292 -1490
- data/lib/sequel/duckdb/helpers/copier.rb +50 -0
- data/lib/sequel/duckdb/helpers/pathifier.rb +141 -0
- data/lib/sequel/duckdb/version.rb +2 -2
- data/plans/date_arithmetic.md +420 -0
- data/plans/engineering/Sequel.md +471 -0
- data/plans/engineering/duckdb.md +712 -0
- data/plans/engineering/sqlite.md +453 -0
- data/plans/mock_connection_bug.md +333 -0
- data/plans/mock_without_driver_gem.md +371 -0
- data/plans/over_engineering_analysis.md +122 -0
- data/plans/schema_management.md +383 -0
- metadata +47 -27
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sequel
|
|
4
|
+
module DuckDB
|
|
5
|
+
module Helpers
|
|
6
|
+
# Builds COPY SQL statements for exporting DuckDB query results to files.
|
|
7
|
+
class Copier
|
|
8
|
+
def initialize(src, dst, options = Sequel::OPTS)
|
|
9
|
+
@src = src
|
|
10
|
+
@dst = dst
|
|
11
|
+
@options = options
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_sql
|
|
15
|
+
"COPY (#{source}) TO '#{@dst}' #{options_str}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def source
|
|
19
|
+
if @src.is_a?(Sequel::Dataset)
|
|
20
|
+
@src.sql
|
|
21
|
+
else
|
|
22
|
+
@src
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def destination
|
|
27
|
+
Pathname.new(@dst).expand_path.to_s
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def options_str
|
|
31
|
+
opts_str = { format: format }.merge(@options).map { |k, v| format_option(k, v) }.compact.join(", ").strip
|
|
32
|
+
opts_str.empty? ? "" : "(#{opts_str})"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def format_option(key, value)
|
|
36
|
+
key = key.to_s.upcase
|
|
37
|
+
case value
|
|
38
|
+
when true then key
|
|
39
|
+
when false then nil
|
|
40
|
+
else "#{key} #{value.to_s.upcase}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def format
|
|
45
|
+
File.extname(@dst).delete_prefix(".")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sequel
|
|
4
|
+
module DuckDB
|
|
5
|
+
module Helpers
|
|
6
|
+
# Helper class for generating SQL to read data from file paths using DuckDB's read functions.
|
|
7
|
+
#
|
|
8
|
+
# Pathifier converts file paths into appropriate DuckDB read_* function calls based on
|
|
9
|
+
# file extensions. It supports reading parquet, CSV, and JSON files, and can handle
|
|
10
|
+
# single or multiple files with glob patterns.
|
|
11
|
+
#
|
|
12
|
+
# @example Reading a single parquet file
|
|
13
|
+
# pathifier = Pathifier.new("/data/users.parquet")
|
|
14
|
+
# pathifier.to_sql
|
|
15
|
+
# # => Sequel.function(:read_parquet, "['/data/users.parquet']")
|
|
16
|
+
#
|
|
17
|
+
# @example Reading multiple CSV files
|
|
18
|
+
# pathifier = Pathifier.new(["/data/2023.csv", "/data/2024.csv"])
|
|
19
|
+
# pathifier.to_sql
|
|
20
|
+
# # => Sequel.function(:read_csv, "['/data/2023.csv','/data/2024.csv']")
|
|
21
|
+
#
|
|
22
|
+
# @example Using glob patterns
|
|
23
|
+
# pathifier = Pathifier.new("/data/*.parquet")
|
|
24
|
+
# pathifier.to_sql
|
|
25
|
+
# # => Sequel.function(:read_parquet, "['/data/*.parquet']")
|
|
26
|
+
#
|
|
27
|
+
# @example Overriding format detection
|
|
28
|
+
# pathifier = Pathifier.new("/data/file.txt", using: :csv)
|
|
29
|
+
# pathifier.to_sql
|
|
30
|
+
# # => Sequel.function(:read_csv, "['/data/file.txt']")
|
|
31
|
+
class Pathifier
|
|
32
|
+
# Initialize a new Pathifier instance.
|
|
33
|
+
#
|
|
34
|
+
# @param paths [String, Array<String>] Single path or array of paths to files
|
|
35
|
+
# @param options [Hash] Options hash
|
|
36
|
+
# @option options [Symbol, String] :using Force a specific format (:parquet, :csv, or :json)
|
|
37
|
+
# instead of detecting from file extension
|
|
38
|
+
#
|
|
39
|
+
# @raise [Sequel::Error] if no paths are provided
|
|
40
|
+
# @raise [Sequel::Error] if multiple different file extensions are provided
|
|
41
|
+
#
|
|
42
|
+
# @example Single file
|
|
43
|
+
# Pathifier.new("/data/users.parquet")
|
|
44
|
+
#
|
|
45
|
+
# @example Multiple files with same extension
|
|
46
|
+
# Pathifier.new(["/data/2023.csv", "/data/2024.csv"])
|
|
47
|
+
#
|
|
48
|
+
# @example Override format detection
|
|
49
|
+
# Pathifier.new("/data/file.txt", using: :csv)
|
|
50
|
+
def initialize(paths, options = Sequel::OPTS)
|
|
51
|
+
@paths = Array(paths).map { |p| Pathname.new(p) }
|
|
52
|
+
@options = options
|
|
53
|
+
validate!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Validate paths and extensions.
|
|
57
|
+
#
|
|
58
|
+
# @raise [Sequel::Error] if no paths provided
|
|
59
|
+
# @raise [Sequel::Error] if multiple different file extensions provided
|
|
60
|
+
# @return [void]
|
|
61
|
+
# @api private
|
|
62
|
+
def validate!
|
|
63
|
+
raise Sequel::Error, "No paths provided" if @paths.empty?
|
|
64
|
+
|
|
65
|
+
return unless extnames.size > 1
|
|
66
|
+
|
|
67
|
+
raise Sequel::Error, "Multiple different file extensions provided: #{extnames.join(", ")}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get unique file extensions from all paths.
|
|
71
|
+
#
|
|
72
|
+
# @return [Array<String>] Array of unique file extensions (e.g., [".parquet"])
|
|
73
|
+
#
|
|
74
|
+
# @example
|
|
75
|
+
# pathifier = Pathifier.new(["/data/a.csv", "/data/b.csv"])
|
|
76
|
+
# pathifier.extnames
|
|
77
|
+
# # => [".csv"]
|
|
78
|
+
def extnames
|
|
79
|
+
@paths.map(&:extname).uniq
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Determine the format to use for reading files.
|
|
83
|
+
#
|
|
84
|
+
# Returns the format specified in :using option, or detects format from
|
|
85
|
+
# the file extension of the first path.
|
|
86
|
+
#
|
|
87
|
+
# @return [Symbol] Format symbol (:parquet, :csv, or :json)
|
|
88
|
+
#
|
|
89
|
+
# @example From file extension
|
|
90
|
+
# pathifier = Pathifier.new("/data/users.parquet")
|
|
91
|
+
# pathifier.to_format
|
|
92
|
+
# # => :parquet
|
|
93
|
+
#
|
|
94
|
+
# @example From :using option
|
|
95
|
+
# pathifier = Pathifier.new("/data/file.txt", using: :csv)
|
|
96
|
+
# pathifier.to_format
|
|
97
|
+
# # => :csv
|
|
98
|
+
def to_format
|
|
99
|
+
@options.fetch(:using, extnames.first.delete_prefix(".")).to_sym
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Generate SQL function call for reading the files.
|
|
103
|
+
#
|
|
104
|
+
# Creates a Sequel function expression that calls the appropriate DuckDB
|
|
105
|
+
# read function (read_parquet, read_csv, or read_json) with an array of
|
|
106
|
+
# file paths.
|
|
107
|
+
#
|
|
108
|
+
# @return [Sequel::SQL::Function] SQL function expression
|
|
109
|
+
#
|
|
110
|
+
# @raise [Sequel::Error] if format is not supported (:parquet, :csv, or :json)
|
|
111
|
+
#
|
|
112
|
+
# @example Single file
|
|
113
|
+
# pathifier = Pathifier.new("/data/users.parquet")
|
|
114
|
+
# pathifier.to_sql
|
|
115
|
+
# # => #<Sequel::SQL::Function @name=>:read_parquet, @args=>[...]>
|
|
116
|
+
#
|
|
117
|
+
# @example Multiple files
|
|
118
|
+
# pathifier = Pathifier.new(["/data/a.csv", "/data/b.csv"])
|
|
119
|
+
# sql = pathifier.to_sql
|
|
120
|
+
# db.literal(sql)
|
|
121
|
+
# # => "read_csv(['/data/a.csv','/data/b.csv'])"
|
|
122
|
+
def to_sql
|
|
123
|
+
paths_sql = @paths.map { |p| "'#{p}'" }.join(",").then do |paths_arr|
|
|
124
|
+
Sequel::LiteralString.new("[#{paths_arr}]")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
Sequel.function(read_function_name, paths_sql)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def read_function_name
|
|
131
|
+
case to_format
|
|
132
|
+
when :parquet then :read_parquet
|
|
133
|
+
when :csv then :read_csv
|
|
134
|
+
when :json then :read_json
|
|
135
|
+
else raise Sequel::Error, "Unsupported :using type: #{to_format}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -8,9 +8,9 @@ module Sequel
|
|
|
8
8
|
# It follows semantic versioning (SemVer) conventions.
|
|
9
9
|
#
|
|
10
10
|
# @example Getting the version
|
|
11
|
-
# puts Sequel::DuckDB::VERSION
|
|
11
|
+
# puts Sequel::DuckDB::VERSION
|
|
12
12
|
#
|
|
13
13
|
# @since 0.1.0
|
|
14
|
-
VERSION = "0.1
|
|
14
|
+
VERSION = "0.2.1"
|
|
15
15
|
end
|
|
16
16
|
end
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# Date Arithmetic Extension Implementation Plan
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Add support for Sequel's `date_arithmetic` extension to the DuckDB adapter, enabling database-independent date/timestamp interval arithmetic operations.
|
|
6
|
+
|
|
7
|
+
## Research Summary
|
|
8
|
+
|
|
9
|
+
### Sequel's date_arithmetic Extension
|
|
10
|
+
|
|
11
|
+
The extension provides two primary methods:
|
|
12
|
+
|
|
13
|
+
- `Sequel.date_add(expr, interval, opts)` - Adds interval to date/timestamp
|
|
14
|
+
- `Sequel.date_sub(expr, interval, opts)` - Subtracts interval from date/timestamp
|
|
15
|
+
|
|
16
|
+
**Supported interval units:**
|
|
17
|
+
|
|
18
|
+
- `years`, `months`, `weeks`, `days`, `hours`, `minutes`, `seconds`
|
|
19
|
+
|
|
20
|
+
**Input formats:**
|
|
21
|
+
|
|
22
|
+
1. Hash: `{years: 1, months: 2, days: 3}`
|
|
23
|
+
2. ActiveSupport::Duration: `1.year + 2.months + 3.days`
|
|
24
|
+
|
|
25
|
+
**Key features:**
|
|
26
|
+
|
|
27
|
+
- Database-independent API
|
|
28
|
+
- Optional cast type override via `:cast` option
|
|
29
|
+
- Weeks automatically converted to days (weeks × 7)
|
|
30
|
+
- SQL injection protection (rejects String values)
|
|
31
|
+
- Accumulates multiple values for same unit
|
|
32
|
+
|
|
33
|
+
### DuckDB's Interval Arithmetic
|
|
34
|
+
|
|
35
|
+
**Native interval syntax:**
|
|
36
|
+
|
|
37
|
+
```sql
|
|
38
|
+
-- Direct addition with INTERVAL keyword
|
|
39
|
+
DATE '2024-01-15' + INTERVAL 1 YEAR
|
|
40
|
+
DATE '2024-01-15' + INTERVAL 30 DAY
|
|
41
|
+
|
|
42
|
+
-- Multiple interval components
|
|
43
|
+
DATE '2024-01-15' + INTERVAL '1 year 2 months 3 days'
|
|
44
|
+
|
|
45
|
+
-- Dynamic intervals with expressions
|
|
46
|
+
DATE '2024-01-15' + INTERVAL (value) YEAR
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Important characteristics:**
|
|
50
|
+
|
|
51
|
+
1. Adding INTERVAL to DATE returns TIMESTAMP (even for day-only intervals)
|
|
52
|
+
2. Three basis units: months, days, microseconds
|
|
53
|
+
3. Supports both unit keywords and string literals
|
|
54
|
+
4. Parentheses required for variable/expression values
|
|
55
|
+
|
|
56
|
+
**Interval construction patterns:**
|
|
57
|
+
|
|
58
|
+
- Single unit: `INTERVAL 5 DAY`
|
|
59
|
+
- Multiple units in string: `INTERVAL '1 month 5 days 3 hours'`
|
|
60
|
+
- Expression-based: `INTERVAL (column_value) MONTH`
|
|
61
|
+
|
|
62
|
+
## Implementation Strategy
|
|
63
|
+
|
|
64
|
+
### 1. Add DuckDB-specific date_add_sql_append Method
|
|
65
|
+
|
|
66
|
+
**Location:** `lib/sequel/adapters/shared/duckdb.rb`
|
|
67
|
+
|
|
68
|
+
**Approach:** Follow the pattern established in Sequel's date_arithmetic extension by adding a `date_add_sql_append` method to `DatabaseMethods` module.
|
|
69
|
+
|
|
70
|
+
### 2. Implementation Pattern
|
|
71
|
+
|
|
72
|
+
The method should follow DuckDB's two supported approaches:
|
|
73
|
+
|
|
74
|
+
#### Option A: Multiple INTERVAL additions (Recommended)
|
|
75
|
+
|
|
76
|
+
Build expression by chaining interval additions:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
# For date_add(:created_at, years: 1, months: 2, days: 5)
|
|
80
|
+
# Generate: created_at + INTERVAL 1 YEAR + INTERVAL 2 MONTH + INTERVAL 5 DAY
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Advantages:**
|
|
84
|
+
|
|
85
|
+
- Simple, straightforward SQL generation
|
|
86
|
+
- Handles dynamic/expression values naturally
|
|
87
|
+
- Each interval component is explicit
|
|
88
|
+
- Follows DuckDB's native syntax
|
|
89
|
+
|
|
90
|
+
#### Option B: Single composite INTERVAL string
|
|
91
|
+
|
|
92
|
+
Build a single interval string with multiple components:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
# For date_add(:created_at, years: 1, months: 2, days: 5)
|
|
96
|
+
# Generate: created_at + INTERVAL '1 years 2 months 5 days'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Challenges:**
|
|
100
|
+
|
|
101
|
+
- Requires literal values only (no expressions)
|
|
102
|
+
- More complex string building
|
|
103
|
+
- Less flexible for dynamic intervals
|
|
104
|
+
|
|
105
|
+
### 3. Unit Mapping
|
|
106
|
+
|
|
107
|
+
**Sequel → DuckDB unit mapping:**
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
DUCKDB_DURATION_UNITS = {
|
|
111
|
+
years: 'YEAR',
|
|
112
|
+
months: 'MONTH',
|
|
113
|
+
days: 'DAY',
|
|
114
|
+
hours: 'HOUR',
|
|
115
|
+
minutes: 'MINUTE',
|
|
116
|
+
seconds: 'SECOND'
|
|
117
|
+
}.freeze
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Note: Weeks already converted to days by `DateAdd` initialization.
|
|
121
|
+
|
|
122
|
+
### 4. Handling Cast Types
|
|
123
|
+
|
|
124
|
+
DuckDB returns TIMESTAMP when adding intervals to dates. Handle the `:cast` option:
|
|
125
|
+
|
|
126
|
+
- If `cast_type` specified: wrap result in `CAST(... AS cast_type)`
|
|
127
|
+
- Default cast_type: `Time` (TIMESTAMP)
|
|
128
|
+
- For date-only results: user can specify `cast: :date`
|
|
129
|
+
|
|
130
|
+
### 5. Value Handling
|
|
131
|
+
|
|
132
|
+
Support both literal values and SQL expressions:
|
|
133
|
+
|
|
134
|
+
- Numeric literals: `1`, `2.5` → direct interpolation
|
|
135
|
+
- SQL expressions: `Sequel.lit(...)`, column references → use parentheses
|
|
136
|
+
|
|
137
|
+
### 6. Implementation Code Structure
|
|
138
|
+
|
|
139
|
+
**Important:** No registration needed in sequel-duckdb! Sequel's date_arithmetic extension already handles registration (line 253 of date_arithmetic.rb):
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
Dataset.register_extension(:date_arithmetic, SQL::DateAdd::DatasetMethods)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The default implementation checks for adapter overrides via `if defined?(super)` (line 94-96), so the DuckDB adapter just needs to provide `date_add_sql_append` in its `DatasetMethods`, and Sequel will automatically call it.
|
|
146
|
+
|
|
147
|
+
**Key Implementation Details:**
|
|
148
|
+
|
|
149
|
+
1. **DateAdd structure** (confirmed from source):
|
|
150
|
+
|
|
151
|
+
- `da.expr` - the expression/column being added to
|
|
152
|
+
- `da.interval` - Hash with symbol keys (e.g., `{hours: 5, minutes: -2}`)
|
|
153
|
+
- `da.cast_type` - nil or a symbol (e.g., `:date`, `:timestamptz`)
|
|
154
|
+
|
|
155
|
+
2. **date_sub handling**: Sequel's `date_sub` automatically negates values before creating DateAdd:
|
|
156
|
+
|
|
157
|
+
- Numeric values: negated directly (`hours: 5` → `hours: -5`)
|
|
158
|
+
- Expressions: wrapped in negation (`Sequel::SQL::NumericExpression.new(:*, v, -1)`)
|
|
159
|
+
- Adapter only needs to implement `date_add_sql_append` - negation is handled upstream
|
|
160
|
+
|
|
161
|
+
3. **Value types**:
|
|
162
|
+
|
|
163
|
+
- `Numeric` (includes Integer, Float, BigDecimal) - can be used directly
|
|
164
|
+
- Expressions (Sequel::LiteralString, Sequel::SQL::Expression, etc.) - need parentheses
|
|
165
|
+
|
|
166
|
+
4. **Sequel.lit with placeholders**: For SQL injection prevention, use array syntax:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
Sequel.lit(["INTERVAL ", " HOUR"], value) # Safe - value is parameterized
|
|
170
|
+
# NOT: Sequel.lit("INTERVAL #{value} HOUR") # Unsafe string interpolation
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
module Sequel
|
|
175
|
+
module DuckDB
|
|
176
|
+
module DatabaseMethods
|
|
177
|
+
# ... existing methods ...
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Dataset methods for DuckDB-specific SQL generation
|
|
181
|
+
# These methods will be mixed into DuckDB::Database datasets
|
|
182
|
+
module DatasetMethods
|
|
183
|
+
DUCKDB_DURATION_UNITS = {
|
|
184
|
+
years: 'YEAR',
|
|
185
|
+
months: 'MONTH',
|
|
186
|
+
days: 'DAY',
|
|
187
|
+
hours: 'HOUR',
|
|
188
|
+
minutes: 'MINUTE',
|
|
189
|
+
seconds: 'SECOND'
|
|
190
|
+
}.freeze
|
|
191
|
+
|
|
192
|
+
# DuckDB-specific implementation of date arithmetic
|
|
193
|
+
# This will be called by Sequel's date_arithmetic extension
|
|
194
|
+
# via the `super` mechanism when the extension is loaded
|
|
195
|
+
def date_add_sql_append(sql, da)
|
|
196
|
+
expr = da.expr
|
|
197
|
+
interval_hash = da.interval
|
|
198
|
+
cast_type = da.cast_type
|
|
199
|
+
|
|
200
|
+
# Build expression with chained interval additions
|
|
201
|
+
result = expr
|
|
202
|
+
interval_hash.each do |unit, value|
|
|
203
|
+
sql_unit = DUCKDB_DURATION_UNITS[unit]
|
|
204
|
+
next unless sql_unit
|
|
205
|
+
|
|
206
|
+
# Create interval addition
|
|
207
|
+
interval = build_interval_literal(value, sql_unit)
|
|
208
|
+
result = Sequel.+(result, interval)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Apply cast if specified or default to Time (TIMESTAMP)
|
|
212
|
+
# Note: DuckDB returns TIMESTAMP when adding intervals to DATE
|
|
213
|
+
result = Sequel.cast(result, cast_type || Time)
|
|
214
|
+
|
|
215
|
+
literal_append(sql, result)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
def build_interval_literal(value, unit)
|
|
221
|
+
# If value is numeric, use direct syntax with placeholder
|
|
222
|
+
# If value is expression, wrap in parentheses with placeholder
|
|
223
|
+
if value.is_a?(Numeric)
|
|
224
|
+
# Direct numeric: INTERVAL 5 HOUR
|
|
225
|
+
Sequel.lit(["INTERVAL ", " ", ""], value, unit)
|
|
226
|
+
else
|
|
227
|
+
# Expression: INTERVAL (column_name) HOUR
|
|
228
|
+
# Note: expressions already include negation from date_sub
|
|
229
|
+
Sequel.lit(["INTERVAL (", ") ", ""], value, unit)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Make DatasetMethods available to DuckDB datasets
|
|
236
|
+
# This allows the date_add_sql_append override to be found
|
|
237
|
+
DuckDB::Database.dataset_module(DuckDB::DatasetMethods)
|
|
238
|
+
end
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Testing Strategy
|
|
242
|
+
|
|
243
|
+
### Unit Tests Required
|
|
244
|
+
|
|
245
|
+
1. **Basic addition/subtraction:**
|
|
246
|
+
|
|
247
|
+
- Single unit intervals (years, months, days, hours, minutes, seconds)
|
|
248
|
+
- Multiple unit intervals combined
|
|
249
|
+
- Verify weeks → days conversion
|
|
250
|
+
|
|
251
|
+
2. **Value types:**
|
|
252
|
+
|
|
253
|
+
- Numeric literals
|
|
254
|
+
- SQL expressions (column references, calculations)
|
|
255
|
+
- Zero values (should be skipped)
|
|
256
|
+
|
|
257
|
+
3. **Cast handling:**
|
|
258
|
+
|
|
259
|
+
- Default cast to timestamp
|
|
260
|
+
- Explicit cast to date
|
|
261
|
+
- Explicit cast to timestamptz
|
|
262
|
+
|
|
263
|
+
4. **ActiveSupport::Duration support:**
|
|
264
|
+
|
|
265
|
+
- Verify `1.year + 2.months` syntax works
|
|
266
|
+
- Mixed duration objects
|
|
267
|
+
|
|
268
|
+
5. **Edge cases:**
|
|
269
|
+
|
|
270
|
+
- Empty intervals
|
|
271
|
+
- Negative values (via date_sub)
|
|
272
|
+
- Large values
|
|
273
|
+
|
|
274
|
+
### Integration Tests
|
|
275
|
+
|
|
276
|
+
Test against actual DuckDB database:
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
DB.extension :date_arithmetic
|
|
280
|
+
|
|
281
|
+
# Test basic addition
|
|
282
|
+
result = DB[:events]
|
|
283
|
+
.select(Sequel.date_add(:created_at, days: 5).as(:future_date))
|
|
284
|
+
.first
|
|
285
|
+
|
|
286
|
+
# Test subtraction
|
|
287
|
+
result = DB[:events]
|
|
288
|
+
.where(Sequel.date_sub(:expires_at, hours: 24) < Sequel::CURRENT_TIMESTAMP)
|
|
289
|
+
.all
|
|
290
|
+
|
|
291
|
+
# Test complex intervals
|
|
292
|
+
result = DB[:events]
|
|
293
|
+
.select(Sequel.date_add(:start_date, {years: 1, months: 2, days: 15}).as(:end_date))
|
|
294
|
+
.first
|
|
295
|
+
|
|
296
|
+
# Test with cast option
|
|
297
|
+
result = DB[:events]
|
|
298
|
+
.select(Sequel.date_add(:date_only, {days: 7}, cast: :date).as(:next_week))
|
|
299
|
+
.first
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## SQL Output Examples
|
|
303
|
+
|
|
304
|
+
**Input:**
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
Sequel.date_add(:created_at, years: 1, months: 2, days: 5)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
**Generated SQL:**
|
|
311
|
+
|
|
312
|
+
```sql
|
|
313
|
+
CAST(created_at + INTERVAL 1 YEAR + INTERVAL 2 MONTH + INTERVAL 5 DAY AS TIMESTAMP)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Input:**
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
Sequel.date_sub(:expires_at, hours: 12, minutes: 30)
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
**Generated SQL:**
|
|
323
|
+
|
|
324
|
+
```sql
|
|
325
|
+
CAST(expires_at + INTERVAL -12 HOUR + INTERVAL -30 MINUTE AS TIMESTAMP)
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
**Input:**
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
Sequel.date_add(:start_date, {days: :duration_column}, cast: :date)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
**Generated SQL:**
|
|
335
|
+
|
|
336
|
+
```sql
|
|
337
|
+
CAST(start_date + INTERVAL (duration_column) DAY AS DATE)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## Files to Modify
|
|
341
|
+
|
|
342
|
+
1. **`lib/sequel/adapters/shared/duckdb.rb`**
|
|
343
|
+
|
|
344
|
+
- Add `DatasetMethods` module with `date_add_sql_append`
|
|
345
|
+
- Add `DUCKDB_DURATION_UNITS` constant
|
|
346
|
+
- Add `build_interval_literal` helper method
|
|
347
|
+
- Use `DuckDB::Database.dataset_module(DuckDB::DatasetMethods)` to make methods available
|
|
348
|
+
- NO registration needed - Sequel's extension handles it
|
|
349
|
+
|
|
350
|
+
2. **`spec/sequel/adapters/date_arithmetic_spec.rb`** (create new)
|
|
351
|
+
|
|
352
|
+
- Unit tests for date_add_sql_append
|
|
353
|
+
- Integration tests with actual database
|
|
354
|
+
- Edge case coverage
|
|
355
|
+
|
|
356
|
+
3. **`README.md`** (optional documentation)
|
|
357
|
+
|
|
358
|
+
- Add date_arithmetic to supported extensions list
|
|
359
|
+
- Include usage examples
|
|
360
|
+
|
|
361
|
+
## Potential Issues & Considerations
|
|
362
|
+
|
|
363
|
+
1. **Type casting behavior:**
|
|
364
|
+
|
|
365
|
+
- DuckDB returns TIMESTAMP when adding intervals to DATE
|
|
366
|
+
- Users expecting DATE output need explicit `cast: :date`
|
|
367
|
+
- Document this behavior clearly
|
|
368
|
+
|
|
369
|
+
2. **Expression vs literal handling:**
|
|
370
|
+
|
|
371
|
+
- Literals can be used directly: `INTERVAL 5 DAY`
|
|
372
|
+
- Expressions need parentheses: `INTERVAL (column) DAY`
|
|
373
|
+
- Distinguish between Numeric and other values
|
|
374
|
+
|
|
375
|
+
3. **Interval order:**
|
|
376
|
+
|
|
377
|
+
- Hash iteration order in Ruby 1.9+ is insertion order
|
|
378
|
+
- DateAdd initializes with `Hash.new(0)` then converts to Hash
|
|
379
|
+
- Order shouldn't matter for interval addition (commutative)
|
|
380
|
+
|
|
381
|
+
4. **PostgreSQL compatibility:**
|
|
382
|
+
|
|
383
|
+
- DuckDB's interval syntax is similar to PostgreSQL
|
|
384
|
+
- Can reference PostgreSQL implementation for patterns
|
|
385
|
+
- Differences: DuckDB doesn't have make_interval function
|
|
386
|
+
|
|
387
|
+
5. **Extension loading:**
|
|
388
|
+
|
|
389
|
+
- Extension must be loaded at Database level: `DB.extension :date_arithmetic`
|
|
390
|
+
- Sequel's extension registers its `DatasetMethods` automatically
|
|
391
|
+
- The `if defined?(super)` check in Sequel's implementation will call DuckDB's override
|
|
392
|
+
- No registration needed in sequel-duckdb - just provide the override method
|
|
393
|
+
|
|
394
|
+
## Implementation Checklist
|
|
395
|
+
|
|
396
|
+
- [ ] Create `DatasetMethods` module in shared/duckdb.rb
|
|
397
|
+
- [ ] Implement `date_add_sql_append` method
|
|
398
|
+
- [ ] Add `DUCKDB_DURATION_UNITS` constant mapping
|
|
399
|
+
- [ ] Implement `build_interval_literal` helper
|
|
400
|
+
- [ ] Use `dataset_module` to make methods available (NO explicit registration needed)
|
|
401
|
+
- [ ] Write comprehensive specs
|
|
402
|
+
- [ ] Test with literal numeric values
|
|
403
|
+
- [ ] Test with SQL expression values
|
|
404
|
+
- [ ] Test cast option handling
|
|
405
|
+
- [ ] Test ActiveSupport::Duration integration
|
|
406
|
+
- [ ] Test date_sub (negative intervals)
|
|
407
|
+
- [ ] Verify all 6 interval units work
|
|
408
|
+
- [ ] Document any DuckDB-specific behaviors
|
|
409
|
+
- [ ] Update README if needed
|
|
410
|
+
|
|
411
|
+
## Success Criteria
|
|
412
|
+
|
|
413
|
+
1. All Sequel date_arithmetic API methods work correctly
|
|
414
|
+
2. Both numeric literals and SQL expressions supported as interval values
|
|
415
|
+
3. Cast option properly controls output type
|
|
416
|
+
4. ActiveSupport::Duration objects handled correctly
|
|
417
|
+
5. Generated SQL is clean and efficient
|
|
418
|
+
6. No SQL injection vulnerabilities
|
|
419
|
+
7. Comprehensive test coverage (>95%)
|
|
420
|
+
8. Consistent with other DuckDB adapter patterns
|