sequelizer 0.1.4 → 0.1.6

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/.claude/settings.local.json +64 -0
  3. data/.devcontainer/.p10k.zsh +1713 -0
  4. data/.devcontainer/.zshrc +29 -0
  5. data/.devcontainer/Dockerfile +137 -0
  6. data/.devcontainer/copy-claude-credentials.sh +32 -0
  7. data/.devcontainer/devcontainer.json +102 -0
  8. data/.devcontainer/init-firewall.sh +123 -0
  9. data/.devcontainer/setup-credentials.sh +95 -0
  10. data/.github/workflows/test.yml +1 -1
  11. data/.gitignore +6 -1
  12. data/.overcommit.yml +73 -0
  13. data/.rubocop.yml +167 -0
  14. data/CHANGELOG.md +24 -0
  15. data/CLAUDE.md +219 -0
  16. data/Gemfile +6 -2
  17. data/Gemfile.lock +158 -0
  18. data/Guardfile +1 -1
  19. data/Rakefile +28 -3
  20. data/lib/sequel/extensions/cold_col.rb +436 -0
  21. data/lib/sequel/extensions/db_opts.rb +65 -4
  22. data/lib/sequel/extensions/make_readyable.rb +148 -30
  23. data/lib/sequel/extensions/more_sql.rb +76 -0
  24. data/lib/sequel/extensions/settable.rb +64 -0
  25. data/lib/sequel/extensions/sql_recorder.rb +85 -0
  26. data/lib/sequel/extensions/unionize.rb +169 -0
  27. data/lib/sequel/extensions/usable.rb +30 -1
  28. data/lib/sequelizer/cli.rb +61 -18
  29. data/lib/sequelizer/connection_maker.rb +54 -72
  30. data/lib/sequelizer/env_config.rb +6 -6
  31. data/lib/sequelizer/gemfile_modifier.rb +23 -21
  32. data/lib/sequelizer/monkey_patches/database_in_after_connect.rb +7 -5
  33. data/lib/sequelizer/options.rb +97 -18
  34. data/lib/sequelizer/options_hash.rb +2 -0
  35. data/lib/sequelizer/version.rb +3 -1
  36. data/lib/sequelizer/yaml_config.rb +9 -3
  37. data/lib/sequelizer.rb +65 -9
  38. data/sequelizer.gemspec +12 -7
  39. data/test/lib/sequel/extensions/test_cold_col.rb +251 -0
  40. data/test/lib/sequel/extensions/test_db_opts.rb +10 -8
  41. data/test/lib/sequel/extensions/test_make_readyable.rb +199 -28
  42. data/test/lib/sequel/extensions/test_more_sql.rb +132 -0
  43. data/test/lib/sequel/extensions/test_settable.rb +109 -0
  44. data/test/lib/sequel/extensions/test_sql_recorder.rb +231 -0
  45. data/test/lib/sequel/extensions/test_unionize.rb +76 -0
  46. data/test/lib/sequel/extensions/test_usable.rb +5 -2
  47. data/test/lib/sequelizer/test_connection_maker.rb +21 -17
  48. data/test/lib/sequelizer/test_env_config.rb +5 -2
  49. data/test/lib/sequelizer/test_gemfile_modifier.rb +7 -6
  50. data/test/lib/sequelizer/test_options.rb +14 -9
  51. data/test/lib/sequelizer/test_yaml_config.rb +13 -12
  52. data/test/test_helper.rb +36 -8
  53. metadata +107 -28
  54. data/lib/sequel/extensions/sqls.rb +0 -31
@@ -0,0 +1,436 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # The cold_col extension adds support for determining dataset column information
5
+ # without executing queries against a live database. This "cold column" functionality
6
+ # is useful for testing, development, and static analysis scenarios where
7
+ # database connections may not be available or desirable.
8
+ #
9
+ # The extension maintains schema information through three sources:
10
+ # - Pre-loaded schemas from YAML files via load_schema
11
+ # - Automatically recorded tables/views created during the session
12
+ # - Manually added table schemas via add_table_schema
13
+ #
14
+ # Basic usage:
15
+ #
16
+ # db = Sequel.mock.extension(:cold_col)
17
+ #
18
+ # # Load schema from YAML file
19
+ # db.load_schema('schemas.yml')
20
+ #
21
+ # # Or add schema manually
22
+ # db.add_table_schema(:users, [[:id, {}], [:name, {}], [:email, {}]])
23
+ #
24
+ # # Now datasets can determine columns without database queries
25
+ # ds = db[:users].select(:name, :email)
26
+ # ds.columns # => [:name, :email]
27
+ #
28
+ # The extension supports complex queries including JOINs, CTEs, subqueries,
29
+ # and aliased tables. Schema YAML files should follow this format:
30
+ #
31
+ # users:
32
+ # columns:
33
+ # id: { type: integer, primary_key: true }
34
+ # name: { type: string }
35
+ # email: { type: string }
36
+ #
37
+ # You can load the extension into the database using:
38
+ #
39
+ # DB.extension :cold_col
40
+
41
+ require 'active_support/core_ext/object/try'
42
+ require 'active_support/core_ext/object/blank'
43
+
44
+ module Sequel
45
+
46
+ module ColdColDatabase
47
+
48
+ # Internal schema registry for managing column information across different sources.
49
+ # This class centralizes the storage and retrieval of table/view column metadata
50
+ # from multiple sources including created tables, views, and manually loaded schemas.
51
+ class SchemaRegistry
52
+
53
+ # Initialize a new schema registry for the given database.
54
+ #
55
+ # @param db [Sequel::Database] the database instance this registry belongs to
56
+ def initialize(db)
57
+ @db = db
58
+ @created_tables = {} # Tables created during the session
59
+ @created_views = {} # Views created during the session
60
+ @schemas = {} # Manually loaded/added schemas
61
+ end
62
+
63
+ # Add schema information for a table that was manually specified.
64
+ # This method stores schemas added via add_table_schema or load_schema.
65
+ #
66
+ # @param name [String, Symbol] the table name
67
+ # @param columns [Array] array of [column_name, column_info] pairs
68
+ def add_schema(name, columns)
69
+ Sequel.synchronize { @schemas[name.to_s] = columns }
70
+ end
71
+
72
+ # Record column information for a table created during the session.
73
+ # This method is called automatically when CREATE TABLE statements are executed.
74
+ #
75
+ # @param name [String] the literal table name from the database
76
+ # @param columns [Array] array of [column_name, column_info] pairs
77
+ def add_created_table(name, columns)
78
+ Sequel.synchronize { @created_tables[name] = columns }
79
+ end
80
+
81
+ # Record column information for a view created during the session.
82
+ # This method is called automatically when CREATE VIEW statements are executed.
83
+ #
84
+ # @param name [String] the literal view name from the database
85
+ # @param columns [Array] array of [column_name, column_info] pairs
86
+ def add_created_view(name, columns)
87
+ Sequel.synchronize { @created_views[name] = columns }
88
+ end
89
+
90
+ # Find column information for a given table/view name.
91
+ # This method searches through all available registries in priority order:
92
+ # 1. Created views (highest priority - most recent)
93
+ # 2. Created tables (medium priority - session specific)
94
+ # 3. Loaded schemas (lowest priority - external definitions)
95
+ #
96
+ # @param name [String, Symbol] the table/view name to look up
97
+ # @return [Array<Symbol>, nil] array of column names as symbols, or nil if not found
98
+ def find_columns(name)
99
+ table_name = name.to_s
100
+ literal_name = @db.literal(name)
101
+
102
+ # Search through registries in priority order
103
+ [@created_views, @created_tables, @schemas].each do |registry|
104
+ next unless registry
105
+
106
+ # Try literal representation first (most common for created tables/views)
107
+ if (columns = Sequel.synchronize { registry[literal_name] })
108
+ return columns.map { |c, _| c }
109
+ end
110
+
111
+ # Try string representation (for manually added schemas)
112
+ if (columns = Sequel.synchronize { registry[table_name] })
113
+ return columns.map { |c, _| c }
114
+ end
115
+
116
+ # Try finding by Sequel::LiteralString key (for test setup compatibility)
117
+ registry.each_key do |key|
118
+ if key.respond_to?(:to_s) && key.to_s == literal_name && (columns = Sequel.synchronize { registry[key] })
119
+ return columns.map { |c, _| c }
120
+ end
121
+ end
122
+ end
123
+
124
+ nil
125
+ end
126
+
127
+ # Merge new schema definitions into the existing schemas registry.
128
+ # Used when loading schemas from YAML files.
129
+ #
130
+ # @param new_schemas [Hash] hash of table_name => column_definitions
131
+ def merge_schemas(new_schemas)
132
+ Sequel.synchronize { @schemas.merge!(new_schemas) }
133
+ end
134
+
135
+ # Directly set the schemas registry (primarily for test setup).
136
+ # This method replaces the entire schemas hash.
137
+ #
138
+ # @param schemas_hash [Hash] the new schemas hash to use
139
+ def set_schemas(schemas_hash)
140
+ Sequel.synchronize { @schemas = schemas_hash }
141
+ end
142
+
143
+ end
144
+
145
+ # Sets up the cold column tracking when the extension is loaded
146
+ def self.extended(db)
147
+ db.extend_datasets(ColdColDataset)
148
+ db.instance_variable_set(:@cold_col_registry, SchemaRegistry.new(db))
149
+ end
150
+
151
+ # Access the schema registry for this database instance.
152
+ #
153
+ # @return [SchemaRegistry] the registry managing column information
154
+ def cold_col_registry
155
+ @cold_col_registry
156
+ end
157
+
158
+ # Load table schema information from a YAML file
159
+ def load_schema(path)
160
+ schema_data = Psych.load_file(path) || {}
161
+ schemas = schema_data.to_h do |table, info|
162
+ columns = (info[:columns] || {}).map { |column_name, col_info| [column_name.to_sym, col_info] }
163
+ [table.to_s, columns]
164
+ end
165
+ cold_col_registry.merge_schemas(schemas)
166
+ end
167
+
168
+ # Manually add schema information for a table
169
+ def add_table_schema(name, info)
170
+ cold_col_registry.add_schema(name, info)
171
+ end
172
+
173
+ def create_table_as(name, sql, options = {})
174
+ super.tap do |_|
175
+ record_table(name, columns_from_sql(sql))
176
+ end
177
+ end
178
+
179
+ def create_table_from_generator(name, generator, options)
180
+ super.tap do |_|
181
+ record_table(name, columns_from_generator(generator))
182
+ end
183
+ end
184
+
185
+ def create_table_sql(name, generator, options)
186
+ super.tap do |_|
187
+ record_table(name, columns_from_generator(generator))
188
+ end
189
+ end
190
+
191
+ def create_view_sql(name, source, options)
192
+ super.tap do |_|
193
+ record_view(name, columns_from_sql(source)) unless options[:dont_record]
194
+ end
195
+ end
196
+
197
+ def record_table(name, columns)
198
+ cold_col_registry.add_created_table(literal(name), columns)
199
+ end
200
+
201
+ def record_view(name, columns)
202
+ cold_col_registry.add_created_view(literal(name), columns)
203
+ end
204
+
205
+ def columns_from_sql(sql)
206
+ sql.columns
207
+ end
208
+
209
+ def columns_from_generator(generator)
210
+ generator.columns.map { |c| [c[:name], c] }
211
+ end
212
+
213
+ end
214
+
215
+ module ColdColDataset
216
+
217
+ # Return the columns for the dataset without executing a query
218
+ def columns
219
+ columns_search
220
+ end
221
+
222
+ def columns_search(opts_chain = nil)
223
+ if (cols = _columns)
224
+ return cols
225
+ end
226
+
227
+ unless (pcs = probable_columns(opts.merge(parent_opts: opts_chain))) && pcs.all?
228
+ raise("Failed to find columns for #{sql}")
229
+ end
230
+
231
+ self.columns = pcs
232
+ end
233
+
234
+ protected
235
+
236
+ WILDCARD = Sequel.lit('*').freeze
237
+
238
+ # Determine the probable columns for a dataset based on its query options.
239
+ # This is the main entry point for column determination logic.
240
+ #
241
+ # @param opts_chain [Hash] the dataset's query options
242
+ # @return [Array<Symbol>] array of probable column names
243
+ def probable_columns(opts_chain)
244
+ cols = opts_chain[:select]
245
+
246
+ return columns_from_sources(opts_chain) if cols.blank?
247
+
248
+ columns_from_select_list(cols, opts_chain)
249
+ end
250
+
251
+ # Extract columns when no explicit SELECT list is present.
252
+ # Returns columns from all source tables (FROM and JOIN clauses).
253
+ #
254
+ # @param opts_chain [Hash] the dataset's query options
255
+ # @return [Array<Symbol>] array of column names from all sources
256
+ def columns_from_sources(opts_chain)
257
+ froms = opts_chain[:from] || []
258
+ joins = (opts_chain[:join] || []).map(&:table_expr)
259
+ (froms + joins).flat_map { |from| fetch_columns(from, opts_chain) }
260
+ end
261
+
262
+ # Extract columns from an explicit SELECT list.
263
+ # Handles three types of column specifications: *, table.*, and explicit columns.
264
+ #
265
+ # @param cols [Array] the SELECT column expressions
266
+ # @param opts_chain [Hash] the dataset's query options
267
+ # @return [Array<Symbol>] array of column names from the SELECT list
268
+ def columns_from_select_list(cols, opts_chain)
269
+ star_columns = extract_star_columns(cols, opts_chain)
270
+ table_star_columns = extract_table_star_columns(cols, opts_chain)
271
+ explicit_columns = extract_explicit_columns(cols)
272
+
273
+ (star_columns + table_star_columns + explicit_columns).flatten
274
+ end
275
+
276
+ # Extract columns when SELECT * is present.
277
+ # Returns all columns from tables in the FROM clause.
278
+ #
279
+ # @param cols [Array] the SELECT column expressions
280
+ # @param opts_chain [Hash] the dataset's query options
281
+ # @return [Array<Symbol>] array of column names from FROM sources
282
+ def extract_star_columns(cols, opts_chain)
283
+ return [] unless select_all?(cols)
284
+
285
+ (opts_chain[:from] || []).flat_map { |from| fetch_columns(from, opts_chain) }
286
+ end
287
+
288
+ # Extract columns from table.* expressions in the SELECT list.
289
+ # Handles cases like SELECT users.*, posts.title.
290
+ #
291
+ # @param cols [Array] the SELECT column expressions
292
+ # @param opts_chain [Hash] the dataset's query options
293
+ # @return [Array<Symbol>] array of column names from table.* expressions
294
+ def extract_table_star_columns(cols, opts_chain)
295
+ cols.select { |c| c.is_a?(Sequel::SQL::ColumnAll) }
296
+ .flat_map { |c| from_named_sources(c.table, opts_chain) }
297
+ end
298
+
299
+ # Extract explicitly named columns from the SELECT list.
300
+ # Handles individual column references and expressions with aliases.
301
+ #
302
+ # @param cols [Array] the SELECT column expressions
303
+ # @return [Array<Symbol>] array of explicit column names
304
+ def extract_explicit_columns(cols)
305
+ cols.reject { |c| c == WILDCARD || c.is_a?(Sequel::SQL::ColumnAll) }
306
+ .map { |c| probable_column_name(c) }
307
+ end
308
+
309
+ private
310
+
311
+ # Check if the SELECT list contains a wildcard (*) expression.
312
+ #
313
+ # @param cols [Array] the SELECT column expressions
314
+ # @return [Boolean] true if SELECT * is present
315
+ def select_all?(cols)
316
+ cols.any? { |c| c == WILDCARD }
317
+ end
318
+
319
+ # Find columns for a named source (table, view, alias, or CTE).
320
+ # This method searches through different types of named sources in order:
321
+ # 1. Aliased FROM expressions (e.g., FROM table AS alias)
322
+ # 2. Common Table Expressions (WITH clauses)
323
+ # 3. Aliased JOIN expressions (e.g., JOIN table AS alias)
324
+ # 4. Schema registry (created/loaded tables and views)
325
+ #
326
+ # @param name [String, Symbol] the source name to look up
327
+ # @param opts_chain [Hash] the dataset's query options
328
+ # @return [Array<Symbol>] array of column names for the source
329
+ # @raise [RuntimeError] if the source cannot be found
330
+ def from_named_sources(name, opts_chain)
331
+ # Try aliased FROM expressions first
332
+ if (columns = find_from_alias(name, opts_chain))
333
+ return columns
334
+ end
335
+
336
+ # Try CTE (WITH clause) expressions
337
+ if (columns = find_cte_columns(name, opts_chain))
338
+ return columns
339
+ end
340
+
341
+ # Try aliased JOIN expressions
342
+ if (columns = find_join_alias(name, opts_chain))
343
+ return columns
344
+ end
345
+
346
+ # Try registry lookup (created tables/views and loaded schemas)
347
+ if (columns = db.cold_col_registry.find_columns(name))
348
+ return columns
349
+ end
350
+
351
+ raise("Failed to find columns for #{literal(name)}")
352
+ end
353
+
354
+ # Find columns for an aliased FROM expression.
355
+ # Searches the FROM clause for expressions like "FROM table AS alias".
356
+ #
357
+ # @param name [String, Symbol] the alias name to find
358
+ # @param opts_chain [Hash] the dataset's query options
359
+ # @return [Array<Symbol>, nil] column names if found, nil otherwise
360
+ def find_from_alias(name, opts_chain)
361
+ from = (opts_chain[:from] || [])
362
+ .select { |f| f.is_a?(Sequel::SQL::AliasedExpression) }
363
+ .detect { |f| literal(f.alias) == literal(name) }
364
+
365
+ from&.expression&.columns_search(opts_chain)
366
+ end
367
+
368
+ # Find columns for a Common Table Expression (CTE).
369
+ # Searches up the query chain for WITH clauses that define the given name.
370
+ #
371
+ # @param name [String, Symbol] the CTE name to find
372
+ # @param opts_chain [Hash] the dataset's query options
373
+ # @return [Array<Symbol>, nil] column names if found, nil otherwise
374
+ def find_cte_columns(name, opts_chain)
375
+ current_opts = opts_chain
376
+
377
+ while current_opts.present?
378
+ with = (current_opts[:with] || []).detect { |wh| literal(wh[:name]) == literal(name) }
379
+ return with[:dataset].columns_search(opts_chain) if with
380
+
381
+ current_opts = current_opts[:parent_opts]
382
+ end
383
+
384
+ nil
385
+ end
386
+
387
+ # Find columns for an aliased JOIN expression.
388
+ # Searches JOIN clauses for expressions like "JOIN table AS alias".
389
+ #
390
+ # @param name [String, Symbol] the join alias name to find
391
+ # @param opts_chain [Hash] the dataset's query options
392
+ # @return [Array<Symbol>, nil] column names if found, nil otherwise
393
+ def find_join_alias(name, opts_chain)
394
+ join = (opts_chain[:join] || []).detect { |jc| literal(jc.table_expr.try(:alias)) == literal(name) }
395
+ return nil unless join
396
+
397
+ join_expr = join.table_expr.expression
398
+ return join_expr.columns_search(opts_chain) if join_expr.is_a?(Sequel::Dataset)
399
+
400
+ # If it's a table reference, look it up in the registry
401
+ db.cold_col_registry.find_columns(join_expr)
402
+ end
403
+
404
+ def fetch_columns(from, opts_chain)
405
+ from = from.expression if from.is_a?(SQL::AliasedExpression)
406
+
407
+ case from
408
+ when Dataset
409
+ from.columns_search(opts_chain)
410
+ when Symbol, SQL::Identifier, SQL::QualifiedIdentifier
411
+ from_named_sources(from, opts_chain)
412
+ end
413
+ end
414
+
415
+ # Return the probable name of the column, or nil if one
416
+ # cannot be determined.
417
+ def probable_column_name(c)
418
+ case c
419
+ when Symbol
420
+ _, c, a = split_symbol(c)
421
+ (a || c).to_sym
422
+ when SQL::Identifier
423
+ c.value.to_sym
424
+ when SQL::QualifiedIdentifier
425
+ c.column.to_sym
426
+ when SQL::AliasedExpression
427
+ a = c.alias
428
+ a.is_a?(SQL::Identifier) ? a.value.to_sym : a.to_sym
429
+ end
430
+ end
431
+
432
+ end
433
+
434
+ Database.register_extension(:cold_col, Sequel::ColdColDatabase)
435
+
436
+ end
@@ -1,41 +1,102 @@
1
1
  module Sequel
2
+
3
+ # = DbOpts
4
+ #
5
+ # Sequel extension that provides database-specific options handling.
6
+ # This extension allows setting database-specific configuration options
7
+ # that get applied during connection establishment.
8
+ #
9
+ # The extension looks for options in the database configuration that match
10
+ # the pattern `{database_type}_db_opt_{option_name}` and converts them to
11
+ # appropriate SQL SET statements.
12
+ #
13
+ # @example
14
+ # # For PostgreSQL, options like:
15
+ # postgres_db_opt_work_mem: '256MB'
16
+ # postgres_db_opt_shared_preload_libraries: 'pg_stat_statements'
17
+ #
18
+ # # Will generate:
19
+ # SET work_mem='256MB'
20
+ # SET shared_preload_libraries='pg_stat_statements'
2
21
  module DbOpts
22
+
23
+ # Handles extraction and application of database-specific options.
3
24
  class DbOptions
4
- attr :db
25
+
26
+ # @!attribute [r] db
27
+ # @return [Sequel::Database] the database instance
28
+ attr_reader :db
29
+
30
+ # Creates a new DbOptions instance for the given database.
31
+ #
32
+ # @param db [Sequel::Database] the database to configure
5
33
  def initialize(db)
6
34
  db.extension :settable
7
35
  @db = db
8
36
  end
9
37
 
38
+ # Returns a hash of database-specific options extracted from the database configuration.
39
+ #
40
+ # @return [Hash] hash of option names to values
10
41
  def to_hash
11
42
  @_to_hash ||= extract_db_opts
12
43
  end
13
44
 
45
+ # Extracts database-specific options from the database configuration.
46
+ #
47
+ # Looks for options matching the pattern `{database_type}_db_opt_{option_name}`
48
+ # and extracts the option name and value.
49
+ #
50
+ # @return [Hash] extracted options with symbolic keys
14
51
  def extract_db_opts
15
52
  opt_regexp = /^#{db.database_type}_db_opt_/i
16
53
 
17
- Hash[db.opts.select { |k, _| k.to_s.match(opt_regexp) }.map { |k, v| [k.to_s.gsub(opt_regexp, '').to_sym, prep_value(k, v)] }]
54
+ db.opts.select do |k, _|
55
+ k.to_s.match(opt_regexp)
56
+ end.to_h { |k, v| [k.to_s.gsub(opt_regexp, '').to_sym, prep_value(k, v)] }
18
57
  end
19
58
 
59
+ # Applies the database options to the given connection.
60
+ #
61
+ # Executes the SQL statements generated from the options on the connection.
62
+ #
63
+ # @param c [Object] the database connection
20
64
  def apply(c)
21
65
  sql_statements.each do |stmt|
22
66
  db.send(:log_connection_execute, c, stmt)
23
67
  end
24
68
  end
25
69
 
26
- def prep_value(k, v)
27
- v =~ /\W/ ? db.literal("#{v}") : v
70
+ # Prepares a value for use in SQL statements.
71
+ #
72
+ # Values containing non-word characters are treated as literals and quoted,
73
+ # while simple values are used as-is.
74
+ #
75
+ # @param _k [Symbol] the option key (unused)
76
+ # @param v [Object] the option value
77
+ # @return [String] the prepared value
78
+ def prep_value(_k, v)
79
+ v =~ /\W/ ? db.literal(v.to_s) : v
28
80
  end
29
81
 
82
+ # Generates SQL SET statements for the database options.
83
+ #
84
+ # @return [Array<String>] array of SQL SET statements
30
85
  def sql_statements
31
86
  db.send(:set_sql, to_hash)
32
87
  end
88
+
33
89
  end
34
90
 
91
+ # Returns a DbOptions instance for this database.
92
+ #
93
+ # @return [DbOptions] the database options handler
35
94
  def db_opts
36
95
  @db_opts ||= DbOptions.new(self)
37
96
  end
97
+
38
98
  end
39
99
 
40
100
  Database.register_extension(:db_opts, DbOpts)
101
+
41
102
  end