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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +21 -0
- data/README.md +557 -0
- data/app/controllers/better_structure_sql/application_controller.rb +61 -0
- data/app/controllers/better_structure_sql/schema_versions_controller.rb +243 -0
- data/app/helpers/better_structure_sql/schema_versions_helper.rb +46 -0
- data/app/views/better_structure_sql/schema_versions/index.html.erb +110 -0
- data/app/views/better_structure_sql/schema_versions/show.html.erb +186 -0
- data/app/views/layouts/better_structure_sql/application.html.erb +105 -0
- data/config/database.yml +3 -0
- data/config/routes.rb +12 -0
- data/lib/better_structure_sql/adapters/base_adapter.rb +234 -0
- data/lib/better_structure_sql/adapters/mysql_adapter.rb +476 -0
- data/lib/better_structure_sql/adapters/mysql_config.rb +32 -0
- data/lib/better_structure_sql/adapters/postgresql_adapter.rb +646 -0
- data/lib/better_structure_sql/adapters/postgresql_config.rb +25 -0
- data/lib/better_structure_sql/adapters/registry.rb +115 -0
- data/lib/better_structure_sql/adapters/sqlite_adapter.rb +644 -0
- data/lib/better_structure_sql/adapters/sqlite_config.rb +26 -0
- data/lib/better_structure_sql/configuration.rb +129 -0
- data/lib/better_structure_sql/database_version.rb +46 -0
- data/lib/better_structure_sql/dependency_resolver.rb +63 -0
- data/lib/better_structure_sql/dumper.rb +544 -0
- data/lib/better_structure_sql/engine.rb +28 -0
- data/lib/better_structure_sql/file_writer.rb +180 -0
- data/lib/better_structure_sql/formatter.rb +70 -0
- data/lib/better_structure_sql/generators/base.rb +33 -0
- data/lib/better_structure_sql/generators/domain_generator.rb +22 -0
- data/lib/better_structure_sql/generators/extension_generator.rb +23 -0
- data/lib/better_structure_sql/generators/foreign_key_generator.rb +43 -0
- data/lib/better_structure_sql/generators/function_generator.rb +33 -0
- data/lib/better_structure_sql/generators/index_generator.rb +50 -0
- data/lib/better_structure_sql/generators/materialized_view_generator.rb +31 -0
- data/lib/better_structure_sql/generators/pragma_generator.rb +23 -0
- data/lib/better_structure_sql/generators/sequence_generator.rb +27 -0
- data/lib/better_structure_sql/generators/table_generator.rb +126 -0
- data/lib/better_structure_sql/generators/trigger_generator.rb +54 -0
- data/lib/better_structure_sql/generators/type_generator.rb +47 -0
- data/lib/better_structure_sql/generators/view_generator.rb +27 -0
- data/lib/better_structure_sql/introspection/extensions.rb +29 -0
- data/lib/better_structure_sql/introspection/foreign_keys.rb +29 -0
- data/lib/better_structure_sql/introspection/functions.rb +29 -0
- data/lib/better_structure_sql/introspection/indexes.rb +29 -0
- data/lib/better_structure_sql/introspection/sequences.rb +29 -0
- data/lib/better_structure_sql/introspection/tables.rb +29 -0
- data/lib/better_structure_sql/introspection/triggers.rb +29 -0
- data/lib/better_structure_sql/introspection/types.rb +37 -0
- data/lib/better_structure_sql/introspection/views.rb +41 -0
- data/lib/better_structure_sql/introspection.rb +31 -0
- data/lib/better_structure_sql/manifest_generator.rb +65 -0
- data/lib/better_structure_sql/migration_patch.rb +196 -0
- data/lib/better_structure_sql/pg_version.rb +44 -0
- data/lib/better_structure_sql/railtie.rb +124 -0
- data/lib/better_structure_sql/schema_loader.rb +168 -0
- data/lib/better_structure_sql/schema_version.rb +86 -0
- data/lib/better_structure_sql/schema_versions.rb +213 -0
- data/lib/better_structure_sql/version.rb +5 -0
- data/lib/better_structure_sql/zip_generator.rb +81 -0
- data/lib/better_structure_sql.rb +81 -0
- data/lib/generators/better_structure_sql/install_generator.rb +44 -0
- data/lib/generators/better_structure_sql/migration_generator.rb +34 -0
- data/lib/generators/better_structure_sql/templates/README +49 -0
- data/lib/generators/better_structure_sql/templates/add_metadata_migration.rb.erb +25 -0
- data/lib/generators/better_structure_sql/templates/better_structure_sql.rb +46 -0
- data/lib/generators/better_structure_sql/templates/migration.rb.erb +26 -0
- data/lib/tasks/better_structure_sql.rake +190 -0
- 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
|