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,544 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ # Orchestrates database schema dumping to SQL files
5
+ #
6
+ # Coordinates introspection, SQL generation, formatting, and file output.
7
+ # Supports both single-file and multi-file dump modes with optional
8
+ # schema version storage.
9
+ class Dumper
10
+ attr_reader :config, :connection, :adapter
11
+
12
+ def initialize(config = BetterStructureSql.configuration, connection = ActiveRecord::Base.connection)
13
+ @config = config
14
+ @connection = connection
15
+ @adapter = Adapters::Registry.adapter_for(connection)
16
+ end
17
+
18
+ # Dumps database schema to configured output path
19
+ #
20
+ # @param store_version [Boolean, nil] Whether to store version (nil uses config default)
21
+ # @return [String, Hash] Single file content or multi-file map
22
+ # @raise [Error] If configuration is invalid
23
+ def dump(store_version: nil)
24
+ config.validate!
25
+
26
+ output_mode = detect_output_mode
27
+
28
+ if output_mode == :multi_file
29
+ dump_multi_file(store_version: store_version)
30
+ else
31
+ dump_single_file(store_version: store_version)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # Detect if we're in single-file or multi-file mode
38
+ def detect_output_mode
39
+ FileWriter.new(config).detect_output_mode(config.output_path)
40
+ end
41
+
42
+ # Original single-file dump logic
43
+ def dump_single_file(store_version: nil)
44
+ output = []
45
+ output << header
46
+ output << extensions_section if config.include_extensions
47
+ output << set_schema_section
48
+ output << custom_types_section if config.include_custom_types
49
+ output << domains_section if config.include_domains
50
+ output << functions_section if config.include_functions
51
+ output << sequences_section if config.include_sequences
52
+ output << tables_section
53
+ output << indexes_section
54
+ output << foreign_keys_section
55
+ output << views_section if config.include_views
56
+ output << materialized_views_section if config.include_materialized_views
57
+ output << triggers_section if config.include_triggers
58
+ output << schema_migrations_section
59
+ output << footer
60
+
61
+ formatted_output = Formatter.new(config).format(output.compact.join("\n\n"))
62
+ write_to_file(formatted_output)
63
+
64
+ # Store version if requested and enabled
65
+ store_version = config.enable_schema_versions if store_version.nil?
66
+ store_schema_version(formatted_output) if store_version && config.enable_schema_versions
67
+
68
+ formatted_output
69
+ end
70
+
71
+ # New multi-file dump logic
72
+ def dump_multi_file(store_version: nil)
73
+ # Generate sections as arrays of SQL strings instead of joined text
74
+ sections = generate_sections_for_multi_file
75
+
76
+ # Write files using FileWriter
77
+ writer = FileWriter.new(config)
78
+ header_content = [header, set_schema_section].compact.join("\n\n")
79
+ file_map = writer.write_multi_file(config.output_path, sections, header_content)
80
+
81
+ # Generate and write manifest
82
+ if config.generate_manifest
83
+ manifest_generator = ManifestGenerator.new(config)
84
+ manifest_content = manifest_generator.generate(file_map)
85
+
86
+ manifest_path = Rails.root.join(config.output_path, '_manifest.json')
87
+ File.write(manifest_path, manifest_content)
88
+ end
89
+
90
+ # Store version if requested and enabled
91
+ # For multi-file, combine all sections for storage
92
+ store_version = config.enable_schema_versions if store_version.nil?
93
+ if store_version && config.enable_schema_versions
94
+ combined_content = combine_sections_for_storage(sections, header_content)
95
+ store_schema_version(combined_content)
96
+ end
97
+
98
+ file_map
99
+ end
100
+
101
+ # Generate sections as structured data (arrays) instead of joined strings
102
+ def generate_sections_for_multi_file
103
+ sections = {}
104
+
105
+ # Extensions
106
+ if config.include_extensions
107
+ extensions = Introspection.fetch_extensions(connection)
108
+ unless extensions.empty?
109
+ generator = Generators::ExtensionGenerator.new(config)
110
+ sections[:extensions] = extensions.map { |ext| generator.generate(ext) }
111
+ end
112
+ end
113
+
114
+ # Custom types (enums, composite)
115
+ if config.include_custom_types
116
+ types = Introspection.fetch_custom_types(connection).reject { |t| t[:type] == 'domain' }
117
+ unless types.empty?
118
+ generator = Generators::TypeGenerator.new(config)
119
+ sections[:types] = types.filter_map { |type| generator.generate(type) }
120
+ end
121
+ end
122
+
123
+ # Domains
124
+ if config.include_domains
125
+ domains = Introspection.fetch_custom_types(connection).select { |t| t[:type] == 'domain' }
126
+ unless domains.empty?
127
+ generator = Generators::DomainGenerator.new(config)
128
+ sections[:domains] = domains.map { |domain| generator.generate(domain) }
129
+ end
130
+ end
131
+
132
+ # Functions
133
+ if config.include_functions
134
+ functions = Introspection.fetch_functions(connection)
135
+ unless functions.empty?
136
+ generator = Generators::FunctionGenerator.new(config)
137
+ sections[:functions] = functions.map { |func| generator.generate(func) }
138
+ end
139
+ end
140
+
141
+ # Sequences
142
+ if config.include_sequences
143
+ sequences = Introspection.fetch_sequences(connection)
144
+ unless sequences.empty?
145
+ generator = Generators::SequenceGenerator.new(config)
146
+ sections[:sequences] = sequences.map { |seq| generator.generate(seq) }
147
+ end
148
+ end
149
+
150
+ # Tables
151
+ tables = Introspection.fetch_tables(connection)
152
+ tables = tables.sort_by { |t| t[:name] } if config.sort_tables
153
+ unless tables.empty?
154
+ # For SQLite, attach foreign keys to each table for inline generation
155
+ if adapter.class.name == 'BetterStructureSql::Adapters::SqliteAdapter'
156
+ all_foreign_keys = Introspection.fetch_foreign_keys(connection)
157
+ tables.each do |table|
158
+ table[:foreign_keys] = all_foreign_keys.select { |fk| fk[:table] == table[:name] }
159
+ end
160
+ end
161
+
162
+ generator = Generators::TableGenerator.new(config, adapter)
163
+ sections[:tables] = tables.map { |table| generator.generate(table) }
164
+ end
165
+
166
+ # Indexes
167
+ indexes = Introspection.fetch_indexes(connection)
168
+ unless indexes.empty?
169
+ generator = Generators::IndexGenerator.new(config)
170
+ sections[:indexes] = indexes.map { |idx| generator.generate(idx) }
171
+ end
172
+
173
+ # Foreign keys
174
+ # SQLite foreign keys are inline with CREATE TABLE, not separate ALTER TABLE statements
175
+ unless adapter.class.name == 'BetterStructureSql::Adapters::SqliteAdapter'
176
+ foreign_keys = Introspection.fetch_foreign_keys(connection)
177
+ unless foreign_keys.empty?
178
+ generator = Generators::ForeignKeyGenerator.new(config)
179
+ sections[:foreign_keys] = foreign_keys.map { |fk| generator.generate(fk) }
180
+ end
181
+ end
182
+
183
+ # Views
184
+ if config.include_views
185
+ views = Introspection.fetch_views(connection)
186
+ unless views.empty?
187
+ generator = Generators::ViewGenerator.new(config)
188
+ sections[:views] = views.map { |view| generator.generate(view) }
189
+ end
190
+ end
191
+
192
+ # Materialized views
193
+ if config.include_materialized_views
194
+ matviews = Introspection.fetch_materialized_views(connection)
195
+ unless matviews.empty?
196
+ generator = Generators::MaterializedViewGenerator.new(config)
197
+ sections[:materialized_views] = matviews.map { |mv| generator.generate(mv) }
198
+ end
199
+ end
200
+
201
+ # Triggers
202
+ if config.include_triggers
203
+ triggers = Introspection.fetch_triggers(connection)
204
+ unless triggers.empty?
205
+ generator = Generators::TriggerGenerator.new(config)
206
+ sections[:triggers] = triggers.map { |trigger| generator.generate(trigger) }
207
+ end
208
+ end
209
+
210
+ # Schema migrations - create batch INSERT statements
211
+ # Each batch INSERT is a complete SQL statement, chunked into groups
212
+ # SQLite doesn't include schema_migrations in structure.sql (Rails manages it separately)
213
+ if adapter.class.name != 'BetterStructureSql::Adapters::SqliteAdapter' && table_exists?('schema_migrations')
214
+ versions = fetch_schema_migration_versions
215
+ unless versions.empty?
216
+ # Chunk versions into groups (each group will be one batch INSERT)
217
+ # Using max_lines - 3 to account for INSERT header + ON CONFLICT footer
218
+ chunk_size = config.max_lines_per_file - 3
219
+ version_chunks = versions.each_slice(chunk_size).to_a
220
+
221
+ # Generate batch INSERT for each chunk
222
+ sections[:migrations] = version_chunks.map do |chunk|
223
+ generate_migrations_batch(chunk)
224
+ end
225
+ end
226
+ end
227
+
228
+ sections
229
+ end
230
+
231
+ # Combine sections for database storage (when versioning is enabled)
232
+ def combine_sections_for_storage(sections, header_content)
233
+ output = [header_content]
234
+
235
+ section_order = %i[
236
+ extensions types domains functions sequences
237
+ tables indexes foreign_keys views materialized_views triggers
238
+ ]
239
+
240
+ section_order.each do |section_key|
241
+ next unless sections.key?(section_key)
242
+
243
+ section_content = sections[section_key]
244
+ next if section_content.blank?
245
+
246
+ # Add section header
247
+ header = "-- #{section_key.to_s.split('_').map(&:capitalize).join(' ')}"
248
+ output << header
249
+ output << section_content.join("\n\n")
250
+ end
251
+
252
+ # Add schema migrations
253
+ migrations = schema_migrations_section
254
+ output << migrations if migrations
255
+
256
+ output << footer
257
+
258
+ Formatter.new(config).format(output.compact.join("\n\n"))
259
+ end
260
+
261
+ def header
262
+ case adapter.class.name
263
+ when 'BetterStructureSql::Adapters::PostgresqlAdapter'
264
+ <<~HEADER.strip
265
+ SET client_encoding = 'UTF8';
266
+ SET standard_conforming_strings = on;
267
+ HEADER
268
+ when 'BetterStructureSql::Adapters::SqliteAdapter'
269
+ # SQLite PRAGMA statements for optimal behavior and compatibility
270
+ <<~HEADER.strip
271
+ PRAGMA foreign_keys = ON;
272
+ PRAGMA defer_foreign_keys = ON;
273
+ HEADER
274
+ when 'BetterStructureSql::Adapters::MysqlAdapter'
275
+ # MySQL doesn't need any header commands (or could set charset, etc.)
276
+ nil
277
+ else
278
+ # Unknown adapter - no header
279
+ nil
280
+ end
281
+ end
282
+
283
+ def extensions_section
284
+ extensions = Introspection.fetch_extensions(connection)
285
+ return nil if extensions.empty?
286
+
287
+ generator = Generators::ExtensionGenerator.new(config)
288
+ # Use appropriate section name based on adapter
289
+ section_name = adapter.class.name == 'BetterStructureSql::Adapters::SqliteAdapter' ? 'PRAGMAs' : 'Extensions'
290
+ lines = ["-- #{section_name}"]
291
+ lines += extensions.map { |ext| generator.generate(ext) }
292
+ lines.join("\n")
293
+ end
294
+
295
+ def set_schema_section
296
+ case adapter.class.name
297
+ when 'BetterStructureSql::Adapters::PostgresqlAdapter'
298
+ "SET search_path TO #{config.search_path};"
299
+ when 'BetterStructureSql::Adapters::SqliteAdapter', 'BetterStructureSql::Adapters::MysqlAdapter'
300
+ # SQLite and MySQL don't use search_path
301
+ nil
302
+ else
303
+ nil
304
+ end
305
+ end
306
+
307
+ def custom_types_section
308
+ # Only include enums and composite types (not domains, they have their own section)
309
+ types = Introspection.fetch_custom_types(connection).reject { |t| t[:type] == 'domain' }
310
+ return nil if types.empty?
311
+
312
+ generator = Generators::TypeGenerator.new(config)
313
+ lines = types.filter_map { |type| generator.generate(type) }
314
+ return nil if lines.empty?
315
+
316
+ (['-- Custom Types'] + lines).join("\n")
317
+ end
318
+
319
+ def sequences_section
320
+ sequences = Introspection.fetch_sequences(connection)
321
+ return nil if sequences.empty?
322
+
323
+ generator = Generators::SequenceGenerator.new(config)
324
+ lines = ['-- Sequences']
325
+ lines += sequences.map { |seq| generator.generate(seq) }
326
+ lines.join("\n")
327
+ end
328
+
329
+ def tables_section
330
+ tables = Introspection.fetch_tables(connection)
331
+ tables = tables.sort_by { |t| t[:name] } if config.sort_tables
332
+
333
+ return '-- Tables' if tables.empty?
334
+
335
+ # For SQLite, attach foreign keys to each table for inline generation
336
+ if adapter.class.name == 'BetterStructureSql::Adapters::SqliteAdapter'
337
+ all_foreign_keys = Introspection.fetch_foreign_keys(connection)
338
+ tables.each do |table|
339
+ table[:foreign_keys] = all_foreign_keys.select { |fk| fk[:table] == table[:name] }
340
+ end
341
+ end
342
+
343
+ generator = Generators::TableGenerator.new(config, adapter)
344
+ lines = []
345
+
346
+ # Add PostgreSQL-specific SET commands only for PostgreSQL
347
+ if adapter.class.name == 'BetterStructureSql::Adapters::PostgresqlAdapter'
348
+ lines << "SET default_tablespace = '';"
349
+ lines << ''
350
+ lines << 'SET default_table_access_method = heap;'
351
+ lines << ''
352
+ end
353
+
354
+ lines << '-- Tables'
355
+ lines += tables.map { |table| generator.generate(table) }
356
+ lines.join("\n\n")
357
+ end
358
+
359
+ def indexes_section
360
+ indexes = Introspection.fetch_indexes(connection)
361
+ return nil if indexes.empty?
362
+
363
+ generator = Generators::IndexGenerator.new(config)
364
+ lines = ['-- Indexes']
365
+ lines += indexes.map { |idx| generator.generate(idx) }
366
+ lines.join("\n")
367
+ end
368
+
369
+ def foreign_keys_section
370
+ # SQLite foreign keys are inline with CREATE TABLE, not separate ALTER TABLE statements
371
+ return nil if adapter.class.name == 'BetterStructureSql::Adapters::SqliteAdapter'
372
+
373
+ foreign_keys = Introspection.fetch_foreign_keys(connection)
374
+ return nil if foreign_keys.empty?
375
+
376
+ generator = Generators::ForeignKeyGenerator.new(config)
377
+ lines = ['-- Foreign Keys']
378
+ lines += foreign_keys.map { |fk| generator.generate(fk) }
379
+ lines.join("\n")
380
+ end
381
+
382
+ def domains_section
383
+ domains = Introspection.fetch_custom_types(connection).select { |t| t[:type] == 'domain' }
384
+ return nil if domains.empty?
385
+
386
+ generator = Generators::DomainGenerator.new(config)
387
+ lines = ['-- Domains']
388
+ lines += domains.map { |domain| generator.generate(domain) }
389
+ lines.join("\n")
390
+ end
391
+
392
+ def functions_section
393
+ functions = Introspection.fetch_functions(connection)
394
+ return nil if functions.empty?
395
+
396
+ generator = Generators::FunctionGenerator.new(config)
397
+ lines = ['-- Functions']
398
+ lines += functions.map { |func| generator.generate(func) }
399
+ lines.join("\n\n")
400
+ end
401
+
402
+ def views_section
403
+ views = Introspection.fetch_views(connection)
404
+ return nil if views.empty?
405
+
406
+ generator = Generators::ViewGenerator.new(config)
407
+ lines = ['-- Views']
408
+ lines += views.map { |view| generator.generate(view) }
409
+ lines.join("\n\n")
410
+ end
411
+
412
+ def materialized_views_section
413
+ matviews = Introspection.fetch_materialized_views(connection)
414
+ return nil if matviews.empty?
415
+
416
+ generator = Generators::MaterializedViewGenerator.new(config)
417
+ lines = ['-- Materialized Views']
418
+ lines += matviews.map { |mv| generator.generate(mv) }
419
+ lines.join("\n\n")
420
+ end
421
+
422
+ def triggers_section
423
+ triggers = Introspection.fetch_triggers(connection)
424
+ return nil if triggers.empty?
425
+
426
+ generator = Generators::TriggerGenerator.new(config)
427
+ lines = ['-- Triggers']
428
+ lines += triggers.map { |trigger| generator.generate(trigger) }
429
+ lines.join("\n\n")
430
+ end
431
+
432
+ def schema_migrations_section
433
+ # SQLite doesn't include schema_migrations in structure.sql
434
+ # Rails manages this table separately
435
+ return nil if adapter.class.name == 'BetterStructureSql::Adapters::SqliteAdapter'
436
+
437
+ return nil unless table_exists?('schema_migrations')
438
+
439
+ versions = fetch_schema_migration_versions
440
+ return nil if versions.empty?
441
+
442
+ # Use adapter-specific quoting
443
+ table_quote = quote_table_name('schema_migrations')
444
+
445
+ lines = ['-- Schema Migrations']
446
+
447
+ # Use adapter-specific conflict resolution and quoting
448
+ case adapter.class.name
449
+ when 'BetterStructureSql::Adapters::PostgresqlAdapter'
450
+ lines << "INSERT INTO #{table_quote} (version) VALUES"
451
+ lines << versions.map { |v| "('#{v}')" }.join(",\n")
452
+ lines << 'ON CONFLICT DO NOTHING;'
453
+ when 'BetterStructureSql::Adapters::MysqlAdapter'
454
+ # MySQL uses INSERT IGNORE and backticks
455
+ lines << "INSERT IGNORE INTO #{table_quote} (version) VALUES"
456
+ lines << versions.map { |v| "('#{v}')" }.join(",\n")
457
+ lines << ';'
458
+ else
459
+ lines << "INSERT INTO #{table_quote} (version) VALUES"
460
+ lines << versions.map { |v| "('#{v}')" }.join(",\n")
461
+ lines << ';'
462
+ end
463
+
464
+ lines.join("\n")
465
+ end
466
+
467
+ def search_path_section
468
+ "SET search_path TO #{config.search_path};"
469
+ end
470
+
471
+ def footer
472
+ '' # Just a blank line to ensure newline at end
473
+ end
474
+
475
+ def write_to_file(content)
476
+ file_path = Rails.root.join(config.output_path)
477
+ FileUtils.mkdir_p(File.dirname(file_path))
478
+ File.write(file_path, content)
479
+ end
480
+
481
+ def table_exists?(table_name)
482
+ connection.table_exists?(table_name)
483
+ end
484
+
485
+ def fetch_schema_migration_versions
486
+ connection.select_values('SELECT version FROM schema_migrations ORDER BY version')
487
+ rescue ActiveRecord::StatementInvalid
488
+ []
489
+ end
490
+
491
+ def generate_migrations_batch(versions)
492
+ return '' if versions.empty?
493
+
494
+ # Use adapter-specific quoting
495
+ table_quote = quote_table_name('schema_migrations')
496
+
497
+ # Generate single batch INSERT with all versions
498
+ # This will be chunked by FileWriter if it exceeds max_lines_per_file
499
+ lines = []
500
+
501
+ # Use adapter-specific conflict resolution and quoting
502
+ case adapter.class.name
503
+ when 'BetterStructureSql::Adapters::PostgresqlAdapter'
504
+ lines << "INSERT INTO #{table_quote} (version) VALUES"
505
+ lines << versions.map { |v| "('#{v}')" }.join(",\n")
506
+ lines << 'ON CONFLICT DO NOTHING;'
507
+ when 'BetterStructureSql::Adapters::MysqlAdapter'
508
+ # MySQL uses INSERT IGNORE and backticks
509
+ lines << "INSERT IGNORE INTO #{table_quote} (version) VALUES"
510
+ lines << versions.map { |v| "('#{v}')" }.join(",\n")
511
+ lines << ';'
512
+ else
513
+ lines << "INSERT INTO #{table_quote} (version) VALUES"
514
+ lines << versions.map { |v| "('#{v}')" }.join(",\n")
515
+ lines << ';'
516
+ end
517
+
518
+ lines.join("\n")
519
+ end
520
+
521
+ def quote_table_name(table_name)
522
+ case adapter.class.name
523
+ when 'BetterStructureSql::Adapters::MysqlAdapter'
524
+ "`#{table_name}`"
525
+ else
526
+ "\"#{table_name}\""
527
+ end
528
+ end
529
+
530
+ def store_schema_version(content)
531
+ pg_version = PgVersion.detect(connection)
532
+
533
+ SchemaVersions.store(
534
+ content: content,
535
+ format_type: 'sql',
536
+ pg_version: pg_version,
537
+ connection: connection
538
+ )
539
+ rescue StandardError => e
540
+ # Log error but don't fail the dump
541
+ warn "Warning: Failed to store schema version: #{e.message}"
542
+ end
543
+ end
544
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ # Rails Engine for mountable web UI
5
+ #
6
+ # Provides controllers and views for browsing stored schema versions
7
+ # through a web interface. Uses Bootstrap 5 from CDN for styling.
8
+ class Engine < ::Rails::Engine
9
+ isolate_namespace BetterStructureSql
10
+
11
+ # Set the root to the gem root directory
12
+ # For development with Docker volume mount, the gem is at /
13
+ # In production, this will be the gem's installed location
14
+ config.root = ENV.fetch('BETTER_STRUCTURE_SQL_ROOT', File.expand_path('../..', __dir__))
15
+
16
+ config.generators do |g|
17
+ g.test_framework :rspec
18
+ g.fixture_replacement :factory_bot
19
+ g.factory_bot dir: 'spec/factories'
20
+ end
21
+
22
+ # No asset pipeline dependencies - we use Bootstrap from CDN
23
+ initializer 'better_structure_sql.assets' do |app|
24
+ # Views and controllers are automatically loaded from app/ directory
25
+ # when using isolate_namespace
26
+ end
27
+ end
28
+ end