sequelizer 0.1.6 → 0.3.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/LICENSE +21 -0
  4. data/README.md +28 -14
  5. data/config/platforms/base.csv +5 -0
  6. data/config/platforms/rdbms/athena.csv +4 -0
  7. data/config/platforms/rdbms/postgres.csv +3 -0
  8. data/config/platforms/rdbms/snowflake.csv +1 -0
  9. data/config/platforms/rdbms/spark.csv +3 -0
  10. data/lib/sequel/extensions/cold_col.rb +41 -17
  11. data/lib/sequel/extensions/funky.rb +136 -0
  12. data/lib/sequel/extensions/make_readyable.rb +18 -10
  13. data/lib/sequel/extensions/platform.rb +300 -0
  14. data/lib/sequel/extensions/smart_select_remove.rb +101 -0
  15. data/lib/sequelizer/options.rb +15 -6
  16. data/lib/sequelizer/version.rb +1 -1
  17. data/lib/sequelizer/yaml_config.rb +0 -1
  18. metadata +90 -57
  19. data/.claude/settings.local.json +0 -64
  20. data/.devcontainer/.p10k.zsh +0 -1713
  21. data/.devcontainer/.zshrc +0 -29
  22. data/.devcontainer/Dockerfile +0 -137
  23. data/.devcontainer/copy-claude-credentials.sh +0 -32
  24. data/.devcontainer/devcontainer.json +0 -102
  25. data/.devcontainer/init-firewall.sh +0 -123
  26. data/.devcontainer/setup-credentials.sh +0 -95
  27. data/.github/workflows/test.yml +0 -14
  28. data/.gitignore +0 -40
  29. data/.overcommit.yml +0 -73
  30. data/.rubocop.yml +0 -167
  31. data/CLAUDE.md +0 -219
  32. data/Gemfile +0 -12
  33. data/Gemfile.lock +0 -158
  34. data/Guardfile +0 -28
  35. data/LICENSE.txt +0 -22
  36. data/Rakefile +0 -35
  37. data/examples/database.with_one_set_of_options.yml +0 -15
  38. data/examples/database.yml +0 -30
  39. data/examples/dot_env.txt +0 -10
  40. data/sequelizer.gemspec +0 -35
  41. data/test/lib/sequel/extensions/test_cold_col.rb +0 -251
  42. data/test/lib/sequel/extensions/test_db_opts.rb +0 -42
  43. data/test/lib/sequel/extensions/test_make_readyable.rb +0 -259
  44. data/test/lib/sequel/extensions/test_more_sql.rb +0 -132
  45. data/test/lib/sequel/extensions/test_settable.rb +0 -109
  46. data/test/lib/sequel/extensions/test_sql_recorder.rb +0 -231
  47. data/test/lib/sequel/extensions/test_unionize.rb +0 -76
  48. data/test/lib/sequel/extensions/test_usable.rb +0 -15
  49. data/test/lib/sequelizer/test_connection_maker.rb +0 -123
  50. data/test/lib/sequelizer/test_env_config.rb +0 -37
  51. data/test/lib/sequelizer/test_gemfile_modifier.rb +0 -88
  52. data/test/lib/sequelizer/test_options.rb +0 -83
  53. data/test/lib/sequelizer/test_yaml_config.rb +0 -83
  54. data/test/test_helper.rb +0 -44
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # The platform extension provides a unified interface for platform-specific
5
+ # database behavior. It uses KVCSV configuration files to define capabilities
6
+ # and preferences, with Ruby classes only for function translations.
7
+ #
8
+ # DB.extension :platform
9
+ # DB.platform.supports?(:cte) # => true (from config)
10
+ # DB.platform.prefers?(:cte) # => true (from config)
11
+ # DB.platform[:interim_schema] # => nil (from config)
12
+ # DB.platform.date_diff(from, to) # => Sequel expression (from code)
13
+ #
14
+ # Configuration is loaded from CSV files in config/platforms/:
15
+ # - base.csv: conservative defaults
16
+ # - rdbms/<adapter>.csv: RDBMS-specific overrides
17
+ # - Additional configs can be stacked via platform_configs option
18
+ #
19
+ # Related module: Sequel::Platform
20
+
21
+ require 'kvcsv'
22
+
23
+ module Sequel
24
+
25
+ # The Platform module provides database platform abstraction through
26
+ # configuration-driven capabilities and code-driven function translations.
27
+ module Platform
28
+
29
+ # Base platform class with KVCSV config loading and default implementations.
30
+ # Subclasses override function translation methods for platform-specific SQL.
31
+ class Base
32
+
33
+ attr_reader :db, :config
34
+
35
+ # Initialize platform with database connection and config paths.
36
+ #
37
+ # @param db [Sequel::Database] The database connection
38
+ # @param config_paths [Array<String>] Paths to CSV config files (stacked in order)
39
+ def initialize(db, *config_paths)
40
+ @db = db
41
+ @config = config_paths.empty? ? {} : KVCSV::Settings.new(*config_paths)
42
+ end
43
+
44
+ # Check if the platform supports a feature.
45
+ #
46
+ # @param feature [Symbol] Feature name (e.g., :cte, :temp_tables)
47
+ # @return [Boolean] true if supported
48
+ #
49
+ # @example
50
+ # platform.supports?(:cte) # checks supports_cte in config
51
+ # platform.supports?(:temp_tables) # checks supports_temp_tables in config
52
+ def supports?(feature)
53
+ config[:"supports_#{feature}"] || false
54
+ end
55
+
56
+ # Check if the platform prefers a feature (may support but not prefer).
57
+ #
58
+ # @param feature [Symbol] Feature name (e.g., :cte, :parquet)
59
+ # @return [Boolean] true if preferred
60
+ #
61
+ # @example
62
+ # platform.prefers?(:cte) # Spark supports CTEs but doesn't prefer them
63
+ # platform.prefers?(:parquet) # Spark prefers parquet format
64
+ def prefers?(feature)
65
+ config[:"prefers_#{feature}"] || false
66
+ end
67
+
68
+ # Access arbitrary config values.
69
+ #
70
+ # @param key [Symbol] Config key
71
+ # @return [Object] Config value or nil
72
+ #
73
+ # @example
74
+ # platform[:interim_schema] # => "scratch"
75
+ # platform[:schema_switching_method] # => "use"
76
+ def [](key)
77
+ config[key]
78
+ end
79
+
80
+ # Fetch config value with default.
81
+ #
82
+ # @param key [Symbol] Config key
83
+ # @param default [Object] Default value if key not found
84
+ # @return [Object] Config value or default
85
+ def fetch(key, default = nil)
86
+ config.respond_to?(:fetch) ? config.fetch(key, default) : (config[key] || default)
87
+ end
88
+
89
+ # ---- Function translations (override in subclasses) ----
90
+
91
+ # Calculate date difference between two dates.
92
+ #
93
+ # @param from [Symbol, Sequel::SQL::Expression] Start date
94
+ # @param to [Symbol, Sequel::SQL::Expression] End date
95
+ # @return [Sequel::SQL::Expression] Date difference expression
96
+ def date_diff(from, to)
97
+ Sequel.function(:datediff, from, to)
98
+ end
99
+
100
+ # Cast expression to date type.
101
+ #
102
+ # @param expr [Object] Expression to cast
103
+ # @return [Sequel::SQL::Cast] Cast expression
104
+ def cast_date(expr)
105
+ Sequel.cast(expr, Date)
106
+ end
107
+
108
+ # Parse string to date with format.
109
+ #
110
+ # @param value [Object] String value to parse
111
+ # @param format [String] Date format string
112
+ # @return [Sequel::SQL::Expression] Parsed date expression
113
+ def str_to_date(value, format)
114
+ Sequel.function(:to_date, value, format)
115
+ end
116
+
117
+ # Calculate days between two dates.
118
+ #
119
+ # @param from [Symbol, Sequel::SQL::Expression] Start date
120
+ # @param to [Symbol, Sequel::SQL::Expression] End date
121
+ # @return [Sequel::SQL::Expression] Days between expression
122
+ def days_between(from, to)
123
+ date_diff(from, to)
124
+ end
125
+
126
+ end
127
+
128
+ # PostgreSQL platform with Postgres-specific function translations.
129
+ class Postgres < Base
130
+
131
+ def date_diff(from, to)
132
+ # Postgres uses date subtraction
133
+ Sequel.lit('(? - ?)', to, from)
134
+ end
135
+
136
+ def days_between(from, to)
137
+ # Postgres date subtraction returns integer days
138
+ Sequel.lit('(? - ?)', to, from)
139
+ end
140
+
141
+ end
142
+
143
+ # Spark platform with Spark SQL-specific function translations.
144
+ class Spark < Base
145
+
146
+ def date_diff(from, to)
147
+ # Spark datediff has reversed argument order (end, start)
148
+ Sequel.function(:datediff, to, from)
149
+ end
150
+
151
+ def str_to_date(value, format)
152
+ Sequel.function(:to_date, Sequel.cast_string(value), format)
153
+ end
154
+
155
+ end
156
+
157
+ # Snowflake platform with Snowflake-specific function translations.
158
+ class Snowflake < Base
159
+
160
+ def date_diff(from, to)
161
+ # Snowflake requires unit parameter
162
+ Sequel.function(:datediff, 'day', from, to)
163
+ end
164
+
165
+ def days_between(from, to)
166
+ Sequel.function(:datediff, 'day', from, to)
167
+ end
168
+
169
+ end
170
+
171
+ # Athena platform (Presto/Trino based) with Athena-specific function translations.
172
+ class Athena < Base
173
+
174
+ def date_diff(from, to)
175
+ # Athena/Presto uses date_diff with unit
176
+ Sequel.function(:date_diff, 'day', from, to)
177
+ end
178
+
179
+ def days_between(from, to)
180
+ Sequel.function(:date_diff, 'day', from, to)
181
+ end
182
+
183
+ end
184
+
185
+ # Map adapter schemes to platform classes
186
+ PLATFORM_CLASSES = {
187
+ postgres: Postgres,
188
+ postgresql: Postgres,
189
+ spark: Spark,
190
+ athena: Athena,
191
+ presto: Athena,
192
+ trino: Athena,
193
+ snowflake: Snowflake,
194
+ }.freeze
195
+
196
+ # Map adapter schemes to config file names
197
+ ADAPTER_CONFIG_NAMES = {
198
+ postgres: 'postgres',
199
+ postgresql: 'postgres',
200
+ spark: 'spark',
201
+ athena: 'athena',
202
+ presto: 'athena',
203
+ trino: 'athena',
204
+ snowflake: 'snowflake',
205
+ }.freeze
206
+
207
+ class << self
208
+
209
+ # Find the config directory, searching gem paths
210
+ def config_dir
211
+ @config_dir ||= find_config_dir
212
+ end
213
+
214
+ # Allow overriding config dir for testing
215
+ attr_writer :config_dir
216
+
217
+ private
218
+
219
+ def find_config_dir
220
+ # Check relative to this file (gem's config)
221
+ gem_config = File.expand_path('../../../config/platforms', __dir__)
222
+ return gem_config if File.directory?(gem_config)
223
+
224
+ # Fallback to working directory
225
+ local_config = File.join(Dir.pwd, 'config/platforms')
226
+ return local_config if File.directory?(local_config)
227
+
228
+ nil
229
+ end
230
+
231
+ end
232
+
233
+ # Build config paths for the given adapter
234
+ #
235
+ # @param adapter_scheme [Symbol] Database adapter scheme
236
+ # @param extra_configs [Array<String>] Additional config paths to stack
237
+ # @return [Array<String>] Ordered config paths
238
+ def self.config_paths_for(adapter_scheme, extra_configs = [])
239
+ paths = []
240
+
241
+ if config_dir
242
+ base_config = File.join(config_dir, 'base.csv')
243
+ paths << base_config if File.exist?(base_config)
244
+
245
+ adapter_name = ADAPTER_CONFIG_NAMES[adapter_scheme]
246
+ if adapter_name
247
+ rdbms_config = File.join(config_dir, 'rdbms', "#{adapter_name}.csv")
248
+ paths << rdbms_config if File.exist?(rdbms_config)
249
+ end
250
+ end
251
+
252
+ paths.concat(extra_configs.select { |p| File.exist?(p) })
253
+ paths
254
+ end
255
+
256
+ # Build platform instance for database
257
+ #
258
+ # @param db [Sequel::Database] Database connection
259
+ # @param extra_configs [Array<String>] Additional config paths
260
+ # @return [Platform::Base] Platform instance
261
+ def self.build_platform(db, extra_configs = [])
262
+ adapter = effective_adapter(db)
263
+ platform_class = PLATFORM_CLASSES.fetch(adapter, Base)
264
+ config_paths = config_paths_for(adapter, extra_configs)
265
+ platform_class.new(db, *config_paths)
266
+ end
267
+
268
+ # Returns the effective database type for platform selection.
269
+ #
270
+ # For real connections, database_type is authoritative. For mock
271
+ # connections whose shared adapter isn't installed (e.g. snowflake,
272
+ # athena), database_type returns :mock; fall back to opts[:host].
273
+ #
274
+ # @param db [Sequel::Database] Database connection
275
+ # @return [Symbol] Effective database type
276
+ def self.effective_adapter(db)
277
+ db_type = db.database_type
278
+ return db_type unless db_type == :mock
279
+
280
+ db.opts[:host]&.to_sym
281
+ end
282
+
283
+ # Extension hook - called when extension is loaded
284
+ def self.extended(db)
285
+ extra_configs = db.opts[:platform_configs] || []
286
+ db.instance_variable_set(:@platform, build_platform(db, extra_configs))
287
+ end
288
+
289
+ # Access the platform instance
290
+ #
291
+ # @return [Platform::Base] Platform instance for this database
292
+ def platform
293
+ @platform
294
+ end
295
+
296
+ end
297
+
298
+ Database.register_extension(:platform, Platform)
299
+
300
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # The smart_select_remove extension overrides select_remove to resolve
5
+ # column information from the dataset's expression tree before falling
6
+ # back to the original select_remove behavior (which executes SQL via
7
+ # columns!).
8
+ #
9
+ # This is essential for lazy query building patterns where datasets
10
+ # reference tables that may not exist yet at query-build time. By
11
+ # walking through from_self chains and inspecting inner SELECT lists,
12
+ # this extension can determine which columns to keep without touching
13
+ # the database.
14
+ #
15
+ # You should load this extension into all of a database's datasets:
16
+ #
17
+ # DB.extension(:smart_select_remove)
18
+ #
19
+ # This automatically loads the select_remove extension as well.
20
+ #
21
+ # Related module: Sequel::SmartSelectRemove
22
+
23
+ module Sequel
24
+
25
+ module SmartSelectRemove
26
+
27
+ # Override select_remove to attempt static column resolution before
28
+ # falling back to the original implementation.
29
+ #
30
+ # When the dataset has no explicit SELECT clause, walks through
31
+ # from_self chains to find inner column expressions and resolves
32
+ # them to outer-scope symbol names. This avoids the columns! call
33
+ # that the original select_remove uses, which would execute SQL.
34
+ #
35
+ # @param cols [Array<Symbol>] columns to remove
36
+ # @return [Sequel::Dataset] dataset with columns removed
37
+ def select_remove(*cols)
38
+ # If there's an explicit select, original behavior handles it
39
+ return super if @opts[:select] && !@opts[:select].empty?
40
+
41
+ # Try to resolve columns by walking the from_self chain
42
+ if (inner_cols = resolve_selected_columns)
43
+ outer_cols = inner_cols.map { |c| resolve_outer_column_name(c) }
44
+ # All columns must be resolvable; bail to super if any are opaque
45
+ return super if outer_cols.any?(&:nil?)
46
+
47
+ return select(*(outer_cols - cols))
48
+ end
49
+
50
+ super
51
+ end
52
+
53
+ private
54
+
55
+ # Walk through from_self chains to find the innermost explicit
56
+ # SELECT list. Returns nil if no explicit selection is found.
57
+ #
58
+ # @param ds [Sequel::Dataset] the dataset to inspect
59
+ # @return [Array, nil] the inner SELECT expressions, or nil
60
+ def resolve_selected_columns(ds = self)
61
+ opts = ds.opts
62
+ if (sel = opts[:select]) && !sel.empty?
63
+ sel
64
+ elsif (from = opts[:from]&.first)
65
+ case from
66
+ when Sequel::Dataset
67
+ resolve_selected_columns(from)
68
+ when Sequel::SQL::AliasedExpression
69
+ resolve_selected_columns(from.expression) if from.expression.is_a?(Sequel::Dataset)
70
+ end
71
+ end
72
+ end
73
+
74
+ # Map an inner SELECT expression to its outer-scope column name.
75
+ # After from_self, inner expressions become subquery output columns
76
+ # referenced by their alias or base name in the outer scope.
77
+ #
78
+ # @param col [Object] a column expression from the inner SELECT
79
+ # @return [Symbol, nil] the outer column name, or nil if unresolvable
80
+ def resolve_outer_column_name(col)
81
+ case col
82
+ when Symbol
83
+ col
84
+ when Sequel::SQL::AliasedExpression
85
+ a = col.alias
86
+ a.is_a?(Symbol) ? a : a.to_sym
87
+ when Sequel::SQL::QualifiedIdentifier
88
+ resolve_outer_column_name(col.column)
89
+ when Sequel::SQL::Identifier
90
+ col.value.to_sym
91
+ end
92
+ end
93
+
94
+ end
95
+
96
+ Database.register_extension(:smart_select_remove) do |db|
97
+ db.extension(:select_remove)
98
+ db.extend_datasets(SmartSelectRemove)
99
+ end
100
+
101
+ end
@@ -1,3 +1,4 @@
1
+ require 'uri'
1
2
  require_relative 'yaml_config'
2
3
  require_relative 'env_config'
3
4
  require_relative 'options_hash'
@@ -98,7 +99,10 @@ module Sequelizer
98
99
  opts = OptionsHash.new(passed_options || {}).to_hash
99
100
  sequelizer_options = db_config(opts).merge(opts)
100
101
 
101
- if sequelizer_options[:adapter] =~ /^postgres/
102
+ adapter = sequelizer_options[:adapter]
103
+ adapter ||= URI.parse(sequelizer_options[:url].to_s).scheme if sequelizer_options[:url]
104
+
105
+ if adapter =~ /^postgres/
102
106
  sequelizer_options[:adapter] = 'postgres'
103
107
  paths = %w[search_path schema_search_path schema].map { |key| sequelizer_options.delete(key) }.compact
104
108
 
@@ -143,17 +147,22 @@ module Sequelizer
143
147
  # 1. Create each schema in the search path if it doesn't exist
144
148
  # 2. Set the search_path for the connection
145
149
  #
146
- # @param search_path [String] comma-separated list of PostgreSQL schemas
150
+ # @param search_path [String, Array<String>] PostgreSQL schema names as a
151
+ # comma-separated string, an Array of schema names, or a mixed Array
152
+ # containing comma-separated strings
147
153
  # @return [Proc] callback to execute after connection
148
- # @example
154
+ # @example String input
149
155
  # callback = after_connect('public,app_schema')
150
- # # When called, creates schemas and sets search_path
156
+ # @example Array input
157
+ # callback = after_connect(['public', 'app_schema'])
151
158
  def after_connect(search_path)
159
+ schemas = Array(search_path).flat_map { |p| p.to_s.split(',') }.map(&:strip)
160
+ path_string = schemas.join(', ')
152
161
  proc do |conn|
153
- search_path.split(',').map(&:strip).each do |schema|
162
+ schemas.each do |schema|
154
163
  conn.execute("CREATE SCHEMA IF NOT EXISTS #{schema}")
155
164
  end
156
- conn.execute("SET search_path TO #{search_path}")
165
+ conn.execute("SET search_path TO #{path_string}")
157
166
  end
158
167
  end
159
168
 
@@ -1,6 +1,6 @@
1
1
  module Sequelizer
2
2
 
3
3
  # Version for the gem
4
- VERSION = '0.1.6'.freeze
4
+ VERSION = '0.3.0'.freeze
5
5
 
6
6
  end
@@ -1,5 +1,4 @@
1
1
  require 'psych'
2
- require 'pathname'
3
2
  require 'erb'
4
3
 
5
4
  module Sequelizer