better_structure_sql 0.1.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/LICENSE +21 -0
  4. data/README.md +557 -0
  5. data/app/controllers/better_structure_sql/application_controller.rb +61 -0
  6. data/app/controllers/better_structure_sql/schema_versions_controller.rb +243 -0
  7. data/app/helpers/better_structure_sql/schema_versions_helper.rb +46 -0
  8. data/app/views/better_structure_sql/schema_versions/index.html.erb +110 -0
  9. data/app/views/better_structure_sql/schema_versions/show.html.erb +186 -0
  10. data/app/views/layouts/better_structure_sql/application.html.erb +105 -0
  11. data/config/database.yml +3 -0
  12. data/config/routes.rb +12 -0
  13. data/lib/better_structure_sql/adapters/base_adapter.rb +234 -0
  14. data/lib/better_structure_sql/adapters/mysql_adapter.rb +476 -0
  15. data/lib/better_structure_sql/adapters/mysql_config.rb +32 -0
  16. data/lib/better_structure_sql/adapters/postgresql_adapter.rb +646 -0
  17. data/lib/better_structure_sql/adapters/postgresql_config.rb +25 -0
  18. data/lib/better_structure_sql/adapters/registry.rb +115 -0
  19. data/lib/better_structure_sql/adapters/sqlite_adapter.rb +644 -0
  20. data/lib/better_structure_sql/adapters/sqlite_config.rb +26 -0
  21. data/lib/better_structure_sql/configuration.rb +129 -0
  22. data/lib/better_structure_sql/database_version.rb +46 -0
  23. data/lib/better_structure_sql/dependency_resolver.rb +63 -0
  24. data/lib/better_structure_sql/dumper.rb +544 -0
  25. data/lib/better_structure_sql/engine.rb +28 -0
  26. data/lib/better_structure_sql/file_writer.rb +180 -0
  27. data/lib/better_structure_sql/formatter.rb +70 -0
  28. data/lib/better_structure_sql/generators/base.rb +33 -0
  29. data/lib/better_structure_sql/generators/domain_generator.rb +22 -0
  30. data/lib/better_structure_sql/generators/extension_generator.rb +23 -0
  31. data/lib/better_structure_sql/generators/foreign_key_generator.rb +43 -0
  32. data/lib/better_structure_sql/generators/function_generator.rb +33 -0
  33. data/lib/better_structure_sql/generators/index_generator.rb +50 -0
  34. data/lib/better_structure_sql/generators/materialized_view_generator.rb +31 -0
  35. data/lib/better_structure_sql/generators/pragma_generator.rb +23 -0
  36. data/lib/better_structure_sql/generators/sequence_generator.rb +27 -0
  37. data/lib/better_structure_sql/generators/table_generator.rb +126 -0
  38. data/lib/better_structure_sql/generators/trigger_generator.rb +54 -0
  39. data/lib/better_structure_sql/generators/type_generator.rb +47 -0
  40. data/lib/better_structure_sql/generators/view_generator.rb +27 -0
  41. data/lib/better_structure_sql/introspection/extensions.rb +29 -0
  42. data/lib/better_structure_sql/introspection/foreign_keys.rb +29 -0
  43. data/lib/better_structure_sql/introspection/functions.rb +29 -0
  44. data/lib/better_structure_sql/introspection/indexes.rb +29 -0
  45. data/lib/better_structure_sql/introspection/sequences.rb +29 -0
  46. data/lib/better_structure_sql/introspection/tables.rb +29 -0
  47. data/lib/better_structure_sql/introspection/triggers.rb +29 -0
  48. data/lib/better_structure_sql/introspection/types.rb +37 -0
  49. data/lib/better_structure_sql/introspection/views.rb +41 -0
  50. data/lib/better_structure_sql/introspection.rb +31 -0
  51. data/lib/better_structure_sql/manifest_generator.rb +65 -0
  52. data/lib/better_structure_sql/migration_patch.rb +196 -0
  53. data/lib/better_structure_sql/pg_version.rb +44 -0
  54. data/lib/better_structure_sql/railtie.rb +124 -0
  55. data/lib/better_structure_sql/schema_loader.rb +168 -0
  56. data/lib/better_structure_sql/schema_version.rb +86 -0
  57. data/lib/better_structure_sql/schema_versions.rb +213 -0
  58. data/lib/better_structure_sql/version.rb +5 -0
  59. data/lib/better_structure_sql/zip_generator.rb +81 -0
  60. data/lib/better_structure_sql.rb +81 -0
  61. data/lib/generators/better_structure_sql/install_generator.rb +44 -0
  62. data/lib/generators/better_structure_sql/migration_generator.rb +34 -0
  63. data/lib/generators/better_structure_sql/templates/README +49 -0
  64. data/lib/generators/better_structure_sql/templates/add_metadata_migration.rb.erb +25 -0
  65. data/lib/generators/better_structure_sql/templates/better_structure_sql.rb +46 -0
  66. data/lib/generators/better_structure_sql/templates/migration.rb.erb +26 -0
  67. data/lib/tasks/better_structure_sql.rake +190 -0
  68. metadata +299 -0
@@ -0,0 +1,644 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ module Adapters
5
+ # SQLite adapter implementing introspection via sqlite_master and PRAGMA statements
6
+ #
7
+ # Provides SQLite-specific SQL generation with proper dialect support.
8
+ # This adapter handles SQLite's unique features including PRAGMA settings,
9
+ # inline foreign keys, AUTOINCREMENT, and type affinities.
10
+ class SqliteAdapter < BaseAdapter
11
+ # Introspection methods using sqlite_master and PRAGMA
12
+
13
+ # Fetch extensions (PRAGMA settings for SQLite)
14
+ #
15
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
16
+ # @return [Array<Hash>] Array of PRAGMA setting hashes with :name, :value, :sql
17
+ # @note SQLite doesn't support extensions like PostgreSQL, but returns important PRAGMA settings
18
+ def fetch_extensions(connection)
19
+ # SQLite doesn't support extensions like PostgreSQL, but we can fetch PRAGMA settings
20
+ # Return them in a format compatible with the extensions section
21
+ fetch_pragma_settings(connection)
22
+ end
23
+
24
+ # Fetch important PRAGMA settings for SQLite
25
+ #
26
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
27
+ # @return [Array<Hash>] Array of PRAGMA hashes with :name, :value, :sql
28
+ def fetch_pragma_settings(connection)
29
+ # List of important PRAGMAs to preserve in schema dump
30
+ important_pragmas = %w[
31
+ foreign_keys
32
+ recursive_triggers
33
+ defer_foreign_keys
34
+ journal_mode
35
+ synchronous
36
+ temp_store
37
+ locking_mode
38
+ auto_vacuum
39
+ cache_size
40
+ ]
41
+
42
+ pragmas = []
43
+ important_pragmas.each do |pragma_name|
44
+ result = connection.execute("PRAGMA #{pragma_name}").first
45
+ next unless result
46
+
47
+ value = result.is_a?(Hash) ? (result[pragma_name] || result.values.first) : result[0]
48
+
49
+ # Only include non-default values that make sense to preserve
50
+ next if value.nil? || value.to_s.empty?
51
+ next if pragma_name == 'foreign_keys' && value.to_i.zero? # Skip if FK disabled
52
+
53
+ pragmas << {
54
+ name: pragma_name,
55
+ value: value,
56
+ sql: "PRAGMA #{pragma_name} = #{format_pragma_value(pragma_name, value)};"
57
+ }
58
+ rescue StandardError => e
59
+ # Skip PRAGMAs that fail (might not be supported in this SQLite version)
60
+ Rails.logger.debug { "Skipping PRAGMA #{pragma_name}: #{e.message}" } if defined?(Rails)
61
+ end
62
+
63
+ pragmas
64
+ end
65
+
66
+ # Format PRAGMA value for SQL statement
67
+ #
68
+ # @param pragma_name [String] Name of the PRAGMA
69
+ # @param value [Object] Value of the PRAGMA
70
+ # @return [String] Formatted value (quoted if string, unquoted if numeric)
71
+ def format_pragma_value(pragma_name, value)
72
+ # String values need quotes, numeric values don't
73
+ case pragma_name
74
+ when 'journal_mode', 'locking_mode', 'temp_store', 'synchronous'
75
+ value.to_s.match?(/^\d+$/) ? value.to_s : "'#{value}'"
76
+ else
77
+ value.to_s
78
+ end
79
+ end
80
+
81
+ # Fetch custom types (not supported in SQLite)
82
+ #
83
+ # @param _connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection (unused)
84
+ # @return [Array] Empty array as SQLite doesn't support custom types
85
+ def fetch_custom_types(_connection)
86
+ # SQLite doesn't support custom types
87
+ []
88
+ end
89
+
90
+ # Fetch all tables from the database
91
+ #
92
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
93
+ # @return [Array<Hash>] Array of table hashes with :name, :schema, :sql, :columns, :primary_key, :constraints
94
+ def fetch_tables(connection)
95
+ query = <<~SQL.squish
96
+ SELECT name, sql
97
+ FROM sqlite_master
98
+ WHERE type = 'table'
99
+ AND name NOT LIKE 'sqlite_%'
100
+ AND name != 'schema_migrations'
101
+ AND name != 'ar_internal_metadata'
102
+ ORDER BY name
103
+ SQL
104
+
105
+ connection.execute(query).map do |row|
106
+ table_name = row['name'] || row[0]
107
+ {
108
+ name: table_name,
109
+ schema: 'main',
110
+ sql: row['sql'] || row[1],
111
+ columns: fetch_columns(connection, table_name),
112
+ primary_key: fetch_primary_key(connection, table_name),
113
+ constraints: fetch_constraints(connection, table_name)
114
+ }
115
+ end
116
+ end
117
+
118
+ # Fetch all indexes from the database
119
+ #
120
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
121
+ # @return [Array<Hash>] Array of index hashes with :table, :name, :columns, :unique, :type, :definition
122
+ def fetch_indexes(connection)
123
+ tables = fetch_table_names(connection)
124
+ indexes = []
125
+ skip_origins = %w[pk u].freeze
126
+
127
+ tables.each do |table_name|
128
+ # Get list of indexes for this table
129
+ index_list = connection.execute("PRAGMA index_list(#{quote_identifier(table_name)})")
130
+
131
+ index_list.each do |index_row|
132
+ index_name = index_row['name'] || index_row[1]
133
+ is_unique = (index_row['unique'] || index_row[2]).to_i == 1
134
+ origin = index_row['origin'] || index_row[3] # 'c' = CREATE INDEX, 'u' = UNIQUE constraint, 'pk' = PRIMARY KEY
135
+
136
+ # Skip auto-generated indexes for PRIMARY KEY and UNIQUE constraints
137
+ next if skip_origins.include?(origin)
138
+
139
+ # Get columns for this index
140
+ index_info = connection.execute("PRAGMA index_info(#{quote_identifier(index_name)})")
141
+ columns = index_info.map { |col_row| col_row['name'] || col_row[2] }
142
+
143
+ # Generate CREATE INDEX SQL for compatibility with Dumper/IndexGenerator
144
+ unique_clause = is_unique ? 'UNIQUE ' : ''
145
+ columns_clause = columns.map { |col| quote_identifier(col) }.join(', ')
146
+ definition = "CREATE #{unique_clause}INDEX #{quote_identifier(index_name)} " \
147
+ "ON #{quote_identifier(table_name)} (#{columns_clause})"
148
+
149
+ indexes << {
150
+ table: table_name,
151
+ name: index_name,
152
+ columns: columns,
153
+ unique: is_unique,
154
+ type: 'BTREE', # SQLite uses B-tree by default
155
+ definition: definition # Add definition field for compatibility with IndexGenerator
156
+ }
157
+ end
158
+ end
159
+
160
+ indexes
161
+ end
162
+
163
+ # Fetch all foreign keys from the database
164
+ #
165
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
166
+ # @return [Array<Hash>] Array of foreign key hashes with :table, :name, :column, :foreign_table, :foreign_column, :on_update, :on_delete
167
+ def fetch_foreign_keys(connection)
168
+ tables = fetch_table_names(connection)
169
+ foreign_keys = []
170
+
171
+ tables.each do |table_name|
172
+ fk_list = connection.execute("PRAGMA foreign_key_list(#{quote_identifier(table_name)})")
173
+
174
+ fk_list.each do |fk_row|
175
+ from_col = fk_row['from'] || fk_row[3]
176
+ to_table = fk_row['table'] || fk_row[2]
177
+ to_col = fk_row['to'] || fk_row[4]
178
+
179
+ foreign_keys << {
180
+ table: table_name,
181
+ name: "fk_#{table_name}_#{to_table}_#{from_col}", # Generate name
182
+ column: from_col,
183
+ foreign_table: to_table,
184
+ foreign_column: to_col,
185
+ on_update: fk_row['on_update'] || fk_row[5],
186
+ on_delete: fk_row['on_delete'] || fk_row[6]
187
+ }
188
+ end
189
+ end
190
+
191
+ foreign_keys
192
+ end
193
+
194
+ # Fetch all views from the database
195
+ #
196
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
197
+ # @return [Array<Hash>] Array of view hashes with :schema, :name, :definition, :updatable
198
+ def fetch_views(connection)
199
+ query = <<~SQL.squish
200
+ SELECT name, sql
201
+ FROM sqlite_master
202
+ WHERE type = 'view'
203
+ ORDER BY name
204
+ SQL
205
+
206
+ connection.execute(query).map do |row|
207
+ sql = row['sql'] || row[1]
208
+ # Extract just the SELECT part from CREATE VIEW statement for compatibility
209
+ # with existing ViewGenerator
210
+ definition = if sql&.match(/CREATE\s+VIEW\s+\w+\s+AS\s+(.*)/im)
211
+ ::Regexp.last_match(1)
212
+ else
213
+ sql
214
+ end
215
+
216
+ {
217
+ schema: 'main',
218
+ name: row['name'] || row[0],
219
+ definition: definition || '',
220
+ updatable: false # SQLite views are generally not updatable
221
+ }
222
+ end
223
+ end
224
+
225
+ # Fetch materialized views (not supported in SQLite)
226
+ #
227
+ # @param _connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection (unused)
228
+ # @return [Array] Empty array as SQLite doesn't support materialized views
229
+ def fetch_materialized_views(_connection)
230
+ # SQLite doesn't support materialized views
231
+ []
232
+ end
233
+
234
+ # Fetch functions (not supported in SQLite)
235
+ #
236
+ # @param _connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection (unused)
237
+ # @return [Array] Empty array as SQLite doesn't support stored procedures/functions
238
+ # @note SQLite only supports user-defined functions in C/Ruby, not SQL stored procedures
239
+ def fetch_functions(_connection)
240
+ # SQLite doesn't support stored procedures/functions (only user-defined functions in C/Ruby)
241
+ []
242
+ end
243
+
244
+ # Fetch sequences (not supported in SQLite)
245
+ #
246
+ # @param _connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection (unused)
247
+ # @return [Array] Empty array as SQLite doesn't support sequences (uses AUTOINCREMENT instead)
248
+ def fetch_sequences(_connection)
249
+ # SQLite doesn't have sequences (uses AUTOINCREMENT)
250
+ []
251
+ end
252
+
253
+ # Fetch all triggers from the database
254
+ #
255
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
256
+ # @return [Array<Hash>] Array of trigger hashes with :schema, :name, :table_name, :timing, :event, :definition
257
+ def fetch_triggers(connection)
258
+ query = <<~SQL.squish
259
+ SELECT name, tbl_name, sql
260
+ FROM sqlite_master
261
+ WHERE type = 'trigger'
262
+ ORDER BY tbl_name, name
263
+ SQL
264
+
265
+ connection.execute(query).map do |row|
266
+ # Parse timing and event from SQL
267
+ sql = row['sql'] || row[2] || ''
268
+ timing_match = sql.match(/\b(BEFORE|AFTER|INSTEAD OF)\b/i)
269
+ timing = timing_match ? timing_match.captures.first.upcase : 'AFTER'
270
+
271
+ event_match = sql.match(/\b(INSERT|UPDATE|DELETE)\b/i)
272
+ event = event_match ? event_match.captures.first.upcase : 'INSERT'
273
+
274
+ {
275
+ schema: 'main',
276
+ name: row['name'] || row[0],
277
+ table_name: row['tbl_name'] || row[1],
278
+ timing: timing,
279
+ event: event,
280
+ definition: sql # Use 'definition' to match PostgreSQL adapter
281
+ }
282
+ end
283
+ end
284
+
285
+ # Capability methods - SQLite feature support
286
+
287
+ # Indicates whether SQLite supports extensions
288
+ #
289
+ # @return [Boolean] Always false for SQLite
290
+ def supports_extensions?
291
+ false
292
+ end
293
+
294
+ # Indicates whether SQLite supports materialized views
295
+ #
296
+ # @return [Boolean] Always false for SQLite
297
+ def supports_materialized_views?
298
+ false
299
+ end
300
+
301
+ # Indicates whether SQLite supports custom types
302
+ #
303
+ # @return [Boolean] Always false for SQLite
304
+ def supports_custom_types?
305
+ false
306
+ end
307
+
308
+ # Indicates whether SQLite supports domains
309
+ #
310
+ # @return [Boolean] Always false for SQLite
311
+ def supports_domains?
312
+ false
313
+ end
314
+
315
+ # Indicates whether SQLite supports stored procedures/functions
316
+ #
317
+ # @return [Boolean] Always false (no stored procedures/functions in SQLite)
318
+ def supports_functions?
319
+ false # No stored procedures/functions
320
+ end
321
+
322
+ # Indicates whether SQLite supports triggers
323
+ #
324
+ # @return [Boolean] Always true for SQLite
325
+ def supports_triggers?
326
+ true
327
+ end
328
+
329
+ # Indicates whether SQLite supports sequences
330
+ #
331
+ # @return [Boolean] Always false (uses AUTOINCREMENT instead)
332
+ def supports_sequences?
333
+ false # Uses AUTOINCREMENT instead
334
+ end
335
+
336
+ # Indicates whether SQLite supports check constraints
337
+ #
338
+ # @return [Boolean] Always true (SQLite has always supported CHECK constraints)
339
+ def supports_check_constraints?
340
+ true # SQLite has always supported CHECK constraints
341
+ end
342
+
343
+ # SQL Generation methods - SQLite-specific syntax
344
+
345
+ # Generate CREATE TABLE statement for SQLite
346
+ #
347
+ # @param table [Hash] Table hash with :name, :columns, :primary_key, :foreign_keys
348
+ # @return [String] CREATE TABLE SQL statement
349
+ def generate_table(table)
350
+ sql = table[:sql]
351
+ return sql if sql # Use original SQL from sqlite_master if available
352
+
353
+ # Generate from columns if needed
354
+ lines = ["CREATE TABLE #{quote_identifier(table[:name])} ("]
355
+
356
+ column_defs = table[:columns].map do |col|
357
+ generate_column_definition(col, table[:primary_key])
358
+ end
359
+
360
+ # Add foreign keys inline if present
361
+ table[:foreign_keys]&.each do |fk|
362
+ column_defs << generate_foreign_key_inline(fk)
363
+ end
364
+
365
+ lines << column_defs.map { |col_def| " #{col_def}" }.join(",\n")
366
+ lines << ');'
367
+
368
+ lines.join("\n")
369
+ end
370
+
371
+ # Generate CREATE INDEX statement for SQLite
372
+ #
373
+ # @param index [Hash] Index hash with :name, :table, :columns, :unique
374
+ # @return [String] CREATE INDEX SQL statement
375
+ def generate_index(index)
376
+ unique = index[:unique] ? 'UNIQUE ' : ''
377
+ columns = index[:columns].map { |col| quote_identifier(col) }.join(', ')
378
+
379
+ "CREATE #{unique}INDEX #{quote_identifier(index[:name])} " \
380
+ "ON #{quote_identifier(index[:table])} (#{columns});"
381
+ end
382
+
383
+ # Generate foreign key constraint (inline with table definition)
384
+ #
385
+ # @param fk [Hash] Foreign key hash with :column, :foreign_table, :foreign_column, :on_delete, :on_update
386
+ # @return [String] FOREIGN KEY constraint SQL
387
+ # @note SQLite requires foreign keys inline with CREATE TABLE
388
+ # rubocop:disable Naming/MethodParameterName
389
+ def generate_foreign_key(fk)
390
+ # SQLite requires foreign keys inline with CREATE TABLE
391
+ # This method is for documentation - actual usage is generate_foreign_key_inline
392
+ generate_foreign_key_inline(fk)
393
+ end
394
+
395
+ # Generate CREATE VIEW statement for SQLite
396
+ #
397
+ # @param view [Hash] View hash with :name, :definition
398
+ # @return [String] CREATE VIEW SQL statement
399
+ def generate_view(view)
400
+ definition = view[:definition]
401
+ return definition if /^CREATE\s+VIEW/i.match?(definition) # Already complete
402
+
403
+ "CREATE VIEW #{quote_identifier(view[:name])} AS\n#{definition};"
404
+ end
405
+
406
+ # Generate CREATE TRIGGER statement for SQLite
407
+ #
408
+ # @param trigger [Hash] Trigger hash with :name, :timing, :event, :table_name, :definition, :body
409
+ # @return [String] CREATE TRIGGER SQL statement
410
+ def generate_trigger(trigger)
411
+ definition = trigger[:definition]
412
+ return definition if /^CREATE\s+TRIGGER/i.match?(definition) # Already complete
413
+
414
+ # Generate from components
415
+ timing = trigger[:timing] || 'AFTER'
416
+ event = trigger[:event] || 'INSERT'
417
+ table = trigger[:table_name]
418
+ body = trigger[:body] || trigger[:definition]
419
+
420
+ <<~SQL.strip
421
+ CREATE TRIGGER #{quote_identifier(trigger[:name])}
422
+ #{timing} #{event} ON #{quote_identifier(table)}
423
+ BEGIN
424
+ #{body}
425
+ END;
426
+ SQL
427
+ end
428
+
429
+ # Version detection
430
+
431
+ # Get the current SQLite database version
432
+ #
433
+ # @return [String] Normalized version string (e.g., "3.45.1")
434
+ def database_version
435
+ @database_version ||= begin
436
+ version_string = connection.select_value('SELECT sqlite_version()')
437
+ parse_version(version_string)
438
+ end
439
+ end
440
+
441
+ # Parse SQLite version string into normalized format
442
+ #
443
+ # @param version_string [String] Raw version string from SQLite (e.g., "3.45.1")
444
+ # @return [String] Normalized version (e.g., "3.45.1") or "unknown" if parsing fails
445
+ def parse_version(version_string)
446
+ # Example: "3.45.1"
447
+ match = version_string.match(/(\d+\.\d+\.\d+)/)
448
+ return 'unknown' unless match
449
+
450
+ match[1]
451
+ end
452
+
453
+ private
454
+
455
+ # Helper methods for introspection
456
+
457
+ # Fetch table names from the database
458
+ #
459
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
460
+ # @return [Array<String>] Array of table names
461
+ def fetch_table_names(connection)
462
+ query = <<~SQL.squish
463
+ SELECT name
464
+ FROM sqlite_master
465
+ WHERE type = 'table'
466
+ AND name NOT LIKE 'sqlite_%'
467
+ AND name != 'schema_migrations'
468
+ AND name != 'ar_internal_metadata'
469
+ ORDER BY name
470
+ SQL
471
+
472
+ connection.execute(query).map { |row| row['name'] || row[0] }
473
+ end
474
+
475
+ # Fetch columns for a specific table using PRAGMA table_info
476
+ #
477
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
478
+ # @param table_name [String] Name of the table
479
+ # @return [Array<Hash>] Array of column hashes with :name, :type, :nullable, :default, :primary_key
480
+ def fetch_columns(connection, table_name)
481
+ table_info = connection.execute("PRAGMA table_info(#{quote_identifier(table_name)})")
482
+
483
+ table_info.map do |row|
484
+ {
485
+ name: row['name'] || row[1],
486
+ type: resolve_column_type(row['type'] || row[2]),
487
+ nullable: (row['notnull'] || row[3]).to_i.zero?,
488
+ default: row['dflt_value'] || row[4],
489
+ primary_key: (row['pk'] || row[5]).to_i == 1
490
+ }
491
+ end
492
+ end
493
+
494
+ # Fetch primary key columns for a specific table using PRAGMA table_info
495
+ #
496
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
497
+ # @param table_name [String] Name of the table
498
+ # @return [Array<String>] Array of primary key column names in pk order
499
+ def fetch_primary_key(connection, table_name)
500
+ table_info = connection.execute("PRAGMA table_info(#{quote_identifier(table_name)})")
501
+
502
+ table_info
503
+ .select { |row| (row['pk'] || row[5]).to_i == 1 }
504
+ .sort_by { |row| row['pk'] || row[5] } # Sort by pk order
505
+ .map { |row| row['name'] || row[1] }
506
+ end
507
+
508
+ # Fetch CHECK constraints for a specific table by parsing table SQL
509
+ #
510
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
511
+ # @param table_name [String] Name of the table
512
+ # @return [Array<Hash>] Array of constraint hashes with :name, :definition, :type
513
+ def fetch_constraints(connection, table_name)
514
+ # SQLite stores CHECK constraints in the table SQL
515
+ # Parse from sqlite_master
516
+ query = <<~SQL.squish
517
+ SELECT sql
518
+ FROM sqlite_master
519
+ WHERE type = 'table'
520
+ AND name = '#{table_name}'
521
+ SQL
522
+
523
+ result = connection.execute(query).first
524
+ return [] unless result
525
+
526
+ sql = result['sql'] || result[0]
527
+ return [] unless sql
528
+
529
+ # Extract CHECK constraints from SQL
530
+ # This is a simplified parser - real implementation would be more robust
531
+ checks = []
532
+ sql.scan(/CONSTRAINT\s+(\w+)\s+CHECK\s*\(([^)]+)\)/i) do |match|
533
+ checks << {
534
+ name: match[0],
535
+ definition: match[1],
536
+ type: :check
537
+ }
538
+ end
539
+
540
+ checks
541
+ end
542
+
543
+ # Resolve SQLite column type into normalized format using type affinity
544
+ #
545
+ # @param type_string [String] Raw column type from PRAGMA table_info
546
+ # @return [String] Normalized column type based on SQLite type affinity rules
547
+ def resolve_column_type(type_string)
548
+ # SQLite type affinity mapping
549
+ # Normalize common types
550
+ type_lower = type_string.to_s.downcase
551
+
552
+ case type_lower
553
+ when /^int/
554
+ 'integer'
555
+ when /^varchar/, /^char/, /^text/
556
+ 'text'
557
+ when /^real/, /^float/, /^double/
558
+ 'real'
559
+ when /^decimal/, /^numeric/
560
+ # Keep precision if specified
561
+ type_string
562
+ when /^blob/
563
+ 'blob'
564
+ when /^bool/
565
+ 'boolean'
566
+ when /^date/, /^time/
567
+ # SQLite stores dates/times as TEXT or INTEGER
568
+ type_string
569
+ when /^json/
570
+ 'json' # Stored as TEXT but semantically JSON
571
+ else
572
+ type_string
573
+ end
574
+ end
575
+
576
+ # Generate column definition for CREATE TABLE
577
+ #
578
+ # @param col [Hash] Column hash with :name, :type, :nullable, :default, :primary_key, :extra
579
+ # @param primary_keys [Array<String>] List of primary key column names
580
+ # @return [String] Column definition SQL
581
+ def generate_column_definition(col, primary_keys = [])
582
+ parts = [quote_identifier(col[:name]), col[:type].upcase]
583
+
584
+ # PRIMARY KEY for single-column pk with AUTOINCREMENT
585
+ if col[:primary_key] && primary_keys.length == 1
586
+ parts << 'PRIMARY KEY'
587
+ parts << 'AUTOINCREMENT' if col[:extra]&.include?('auto_increment') || col[:type]&.downcase == 'integer'
588
+ end
589
+
590
+ parts << 'NOT NULL' unless col[:nullable]
591
+ parts << "DEFAULT #{format_default_value(col[:default])}" if col[:default]
592
+
593
+ parts.join(' ')
594
+ end
595
+
596
+ # Generate inline foreign key constraint
597
+ #
598
+ # @param fk [Hash] Foreign key hash with :column, :foreign_table, :foreign_column, :on_delete, :on_update
599
+ # @return [String] FOREIGN KEY constraint SQL
600
+ def generate_foreign_key_inline(fk)
601
+ parts = ["FOREIGN KEY (#{quote_identifier(fk[:column])})"]
602
+ parts << "REFERENCES #{quote_identifier(fk[:foreign_table])}(#{quote_identifier(fk[:foreign_column])})"
603
+ parts << "ON DELETE #{fk[:on_delete]}" if fk[:on_delete]
604
+ parts << "ON UPDATE #{fk[:on_update]}" if fk[:on_update]
605
+
606
+ parts.join(' ')
607
+ end
608
+ # rubocop:enable Naming/MethodParameterName
609
+
610
+ # Quote identifier (table/column name) with double quotes
611
+ #
612
+ # @param name [String] Identifier to quote
613
+ # @return [String] Quoted identifier (e.g., "table_name")
614
+ def quote_identifier(name)
615
+ "\"#{name}\""
616
+ end
617
+
618
+ # Format default value for SQL statement
619
+ #
620
+ # @param value [Object] Default value (can be nil, String, Boolean, or other)
621
+ # @return [String] Formatted default value for SQL
622
+ def format_default_value(value)
623
+ case value
624
+ when nil
625
+ 'NULL'
626
+ when String
627
+ # Check if it looks like a function call (uppercase letters/underscores followed by parentheses)
628
+ # or datetime/current_timestamp keywords
629
+ if value =~ /^[A-Z_]+\(/i || value =~ /^(CURRENT_|datetime|date|time)/i
630
+ value
631
+ else
632
+ "'#{value.gsub("'", "''")}'"
633
+ end
634
+ when TrueClass
635
+ '1'
636
+ when FalseClass
637
+ '0'
638
+ else
639
+ value.to_s
640
+ end
641
+ end
642
+ end
643
+ end
644
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ module Adapters
5
+ # SQLite-specific configuration settings
6
+ #
7
+ # Provides configuration options specific to SQLite database adapter.
8
+ # These settings control which SQLite features are included in schema dumps
9
+ # and database behavior.
10
+ class SqliteConfig
11
+ # @return [Boolean] Whether to include triggers in schema dump
12
+ # @return [Boolean] Whether to include views in schema dump
13
+ # @return [Boolean] Whether to enable foreign key constraints (PRAGMA foreign_keys=ON)
14
+ # @return [Boolean] Whether to use STRICT tables in SQLite 3.37+
15
+ attr_accessor :include_triggers, :include_views, :foreign_keys_enabled, :strict_mode
16
+
17
+ # Initialize SQLite configuration with default values
18
+ def initialize
19
+ @include_triggers = true
20
+ @include_views = true
21
+ @foreign_keys_enabled = true # PRAGMA foreign_keys=ON
22
+ @strict_mode = false # Use STRICT tables in SQLite 3.37+
23
+ end
24
+ end
25
+ end
26
+ end