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,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ # Patches ActiveRecord::Migration to handle multi-file schema directories
5
+ #
6
+ # Rails' maintain_test_schema! calls purge_current_test_schema which tries to
7
+ # read the schema file directly using File.read(), which fails when the schema
8
+ # is a directory (multi-file mode).
9
+ #
10
+ # This patch intercepts that behavior and uses our SchemaLoader instead.
11
+ module MigrationPatch
12
+ # Override schema_cache to handle directory-based schemas
13
+ module SchemaCachePatch
14
+ # Returns the schema cache, handling directory-based schemas
15
+ # @return [ActiveRecord::ConnectionAdapters::SchemaCache] Schema cache
16
+ def schema_cache
17
+ return super unless multi_file_schema?
18
+
19
+ # For multi-file schemas, we need to handle the cache differently
20
+ # Check if cache exists as a file with the expected naming convention
21
+ cache_path = derived_cache_path
22
+
23
+ connection.schema_cache = ActiveRecord::ConnectionAdapters::SchemaCache.load_from(cache_path) if File.exist?(cache_path)
24
+
25
+ super
26
+ rescue Errno::EISDIR
27
+ # If we get EISDIR, it means Rails is trying to read a directory
28
+ # This is expected for multi-file schemas, just return the current cache
29
+ connection.schema_cache
30
+ end
31
+
32
+ private
33
+
34
+ def multi_file_schema?
35
+ return false unless defined?(Rails) && Rails.application
36
+
37
+ schema_path = if Rails.application.config.active_record.schema_format == :sql
38
+ ENV.fetch('SCHEMA', 'db/structure.sql')
39
+ else
40
+ 'db/schema.rb'
41
+ end
42
+
43
+ full_path = Rails.root.join(schema_path)
44
+ File.directory?(full_path)
45
+ end
46
+
47
+ def derived_cache_path
48
+ # Check if using BetterStructureSql config
49
+ base_path = if defined?(BetterStructureSql) && BetterStructureSql.configured?
50
+ BetterStructureSql.configuration.output_path
51
+ elsif Rails.application.config.active_record.schema_format == :sql
52
+ ENV.fetch('SCHEMA', 'db/structure.sql')
53
+ else
54
+ 'db/schema.rb'
55
+ end
56
+
57
+ # For directory mode, use the directory path + _schema_cache.yml
58
+ if Rails.root.join(base_path).directory?
59
+ Rails.root.join(base_path, '_schema_cache.yml')
60
+ else
61
+ # For file mode, use the default Rails cache path
62
+ Rails.root.join('db/schema_cache.yml')
63
+ end
64
+ end
65
+ end
66
+
67
+ # Patch for maintain_test_schema! to handle directory schemas
68
+ module MaintainTestSchemaPatch
69
+ # Maintains test schema, handling directory-based schemas
70
+ # @return [void]
71
+ def maintain_test_schema!
72
+ return super unless should_use_better_structure_sql?
73
+
74
+ # Check if we need to load or purge the schema
75
+ if pending_migrations?
76
+ # Purge the schema first
77
+ purge_current_test_schema_with_directory_support
78
+ # Load the schema using our loader
79
+ load_schema_with_directory_support
80
+ end
81
+ rescue Errno::EISDIR
82
+ # If we still get EISDIR, provide a helpful error message
83
+ raise ActiveRecord::MigrationError,
84
+ "Multi-file schema directory detected at #{schema_file_path}. " \
85
+ 'Set config.replace_default_load = true in config/initializers/better_structure_sql.rb ' \
86
+ 'to enable automatic multi-file schema loading, or run: rails db:schema:load_better'
87
+ end
88
+
89
+ private
90
+
91
+ def should_use_better_structure_sql?
92
+ return false unless defined?(BetterStructureSql) && defined?(Rails)
93
+ return false unless Rails.application.config.active_record.schema_format == :sql
94
+
95
+ # Check if schema path is a directory
96
+ File.directory?(schema_file_path)
97
+ end
98
+
99
+ def schema_file_path
100
+ if defined?(BetterStructureSql) && BetterStructureSql.configured?
101
+ Rails.root.join(BetterStructureSql.configuration.output_path)
102
+ else
103
+ Rails.root.join(ENV.fetch('SCHEMA', 'db/structure.sql'))
104
+ end
105
+ end
106
+
107
+ def pending_migrations?
108
+ # Use ActiveRecord::Base.connection_pool.migration_context for compatibility
109
+ # Different Rails versions have migration_context in different places
110
+ if ActiveRecord::Base.connection_pool.respond_to?(:migration_context)
111
+ ActiveRecord::Base.connection_pool.migration_context.needs_migration?
112
+ elsif ActiveRecord::Base.connection.respond_to?(:migration_context)
113
+ ActiveRecord::Base.connection.migration_context.needs_migration?
114
+ elsif defined?(ActiveRecord::MigrationContext)
115
+ # Fallback: construct migration context manually
116
+ paths = ActiveRecord::Tasks::DatabaseTasks.migrations_paths
117
+ schema_migration = ActiveRecord::Base.connection.schema_migration
118
+ internal_metadata = ActiveRecord::Base.connection.internal_metadata
119
+ ActiveRecord::MigrationContext.new(paths, schema_migration, internal_metadata).needs_migration?
120
+ else
121
+ # Very old Rails, assume migrations are pending to be safe
122
+ true
123
+ end
124
+ end
125
+
126
+ def purge_current_test_schema_with_directory_support
127
+ # Purge the test database
128
+ ActiveRecord::Tasks::DatabaseTasks.purge_current('test')
129
+ rescue ActiveRecord::NoDatabaseError
130
+ # Database doesn't exist, that's fine
131
+ end
132
+
133
+ def load_schema_with_directory_support
134
+ if defined?(BetterStructureSql) && BetterStructureSql.configured?
135
+ # Use our loader which handles both files and directories
136
+ loader = BetterStructureSql::SchemaLoader.new(BetterStructureSql.configuration)
137
+ loader.load
138
+ else
139
+ # Fallback to Rails default
140
+ ActiveRecord::Tasks::DatabaseTasks.load_schema_current(:sql)
141
+ end
142
+ end
143
+ end
144
+
145
+ # Patch for DatabaseTasks to handle directory schemas in development
146
+ module DatabaseTasksPatch
147
+ # Override check_schema_file to handle directories
148
+ # @param filename [String] Path to schema file or directory
149
+ # @return [void]
150
+ def check_schema_file(filename)
151
+ # For directory mode, check if directory exists
152
+ if File.directory?(filename)
153
+ return if Dir.glob(File.join(filename, '**', '*.sql')).any?
154
+
155
+ message = +"#{filename} exists but contains no SQL files. Run `bin/rails db:migrate` to create schema files."
156
+ Kernel.abort message
157
+ end
158
+
159
+ # For file mode, use default Rails behavior
160
+ super
161
+ end
162
+
163
+ # Override schema_sha1 to handle directories
164
+ # @param file [String] Path to schema file or directory
165
+ # @return [String] SHA1 checksum
166
+ def schema_sha1(file)
167
+ if File.directory?(file)
168
+ # For directory mode, generate checksum from all SQL files combined
169
+ # Sort files to ensure deterministic ordering
170
+ sql_files = Dir.glob(File.join(file, '**', '*.sql')).sort
171
+ combined_content = sql_files.map { |f| File.read(f) }.join("\n")
172
+ OpenSSL::Digest::SHA1.hexdigest(combined_content)
173
+ else
174
+ # For file mode, use default Rails behavior
175
+ super
176
+ end
177
+ end
178
+ end
179
+
180
+ # Apply patches when loaded
181
+ def self.apply!
182
+ return unless defined?(ActiveRecord::Migration)
183
+
184
+ # Patch maintain_test_schema! if it exists
185
+ ActiveRecord::Migration.singleton_class.prepend(MaintainTestSchemaPatch) if ActiveRecord::Migration.respond_to?(:maintain_test_schema!)
186
+
187
+ # Patch schema_cache handling
188
+ ActiveRecord::MigrationContext.prepend(SchemaCachePatch) if defined?(ActiveRecord::MigrationContext)
189
+
190
+ # Patch DatabaseTasks for development/production use
191
+ return unless defined?(ActiveRecord::Tasks::DatabaseTasks)
192
+
193
+ ActiveRecord::Tasks::DatabaseTasks.singleton_class.prepend(DatabaseTasksPatch)
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ # Deprecated: Use DatabaseVersion instead.
5
+ # This module is kept for backward compatibility.
6
+ module PgVersion
7
+ # rubocop:disable Rails/Delegate
8
+ # delegate doesn't work for delegating to module constants
9
+ class << self
10
+ # Delegates to DatabaseVersion.detect
11
+ #
12
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
13
+ # @return [String] Database version
14
+ def detect(connection = ActiveRecord::Base.connection)
15
+ DatabaseVersion.detect(connection)
16
+ end
17
+
18
+ # Delegates to DatabaseVersion.parse_version
19
+ #
20
+ # @param version_string [String] Version string
21
+ # @return [String] Parsed version
22
+ def parse_version(version_string)
23
+ DatabaseVersion.parse_version(version_string)
24
+ end
25
+
26
+ # Delegates to DatabaseVersion.major_version
27
+ #
28
+ # @param version_string [String] Version string
29
+ # @return [Integer] Major version number
30
+ def major_version(version_string)
31
+ DatabaseVersion.major_version(version_string)
32
+ end
33
+
34
+ # Delegates to DatabaseVersion.minor_version
35
+ #
36
+ # @param version_string [String] Version string
37
+ # @return [Integer] Minor version number
38
+ def minor_version(version_string)
39
+ DatabaseVersion.minor_version(version_string)
40
+ end
41
+ end
42
+ # rubocop:enable Rails/Delegate
43
+ end
44
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module BetterStructureSql
6
+ # Rails integration for BetterStructureSql
7
+ #
8
+ # Registers rake tasks, initializers, and optionally replaces
9
+ # default Rails schema dump/load tasks with BetterStructureSql versions.
10
+ class Railtie < Rails::Railtie
11
+ railtie_name :better_structure_sql
12
+
13
+ rake_tasks do
14
+ load 'tasks/better_structure_sql.rake'
15
+ end
16
+
17
+ initializer 'better_structure_sql.load_config' do
18
+ config_file = Rails.root.join('config/initializers/better_structure_sql.rb')
19
+ load config_file if config_file.exist?
20
+ end
21
+
22
+ initializer 'better_structure_sql.replace_default_dump', after: 'active_record.set_configs' do
23
+ ActiveSupport.on_load(:active_record) do
24
+ config = BetterStructureSql.configuration
25
+
26
+ # Only replace tasks if using SQL format (structure.sql or directory)
27
+ # If using schema.rb format, silently skip replacement - we can still store versions
28
+ is_ruby_format = config.output_path.to_s.end_with?('.rb')
29
+
30
+ # Always apply migration patch for directory schema support in tests
31
+ # This fixes maintain_test_schema! when using multi-file directory mode
32
+ BetterStructureSql::MigrationPatch.apply!
33
+
34
+ if config.replace_default_dump && !is_ruby_format
35
+ # Automatically set Rails to use SQL schema format
36
+ Rails.application.config.active_record.schema_format = :sql
37
+
38
+ ActiveRecord::Tasks::DatabaseTasks.singleton_class.prepend(DatabaseTasksExtension)
39
+ ActiveRecord::Tasks::DatabaseTasks.singleton_class.prepend(DatabaseTasksDumpInfoExtension)
40
+ # Also prepend path override for dump
41
+ ActiveRecord::Tasks::DatabaseTasks.singleton_class.prepend(DatabaseTasksPathExtension)
42
+ end
43
+
44
+ if config.replace_default_load && !is_ruby_format
45
+ ActiveRecord::Tasks::DatabaseTasks.singleton_class.prepend(DatabaseTasksLoadExtension)
46
+ # Also prepend path override for load
47
+ ActiveRecord::Tasks::DatabaseTasks.singleton_class.prepend(DatabaseTasksPathExtension)
48
+ end
49
+ end
50
+ end
51
+
52
+ # Extension to override schema_dump_path for BetterStructureSql
53
+ module DatabaseTasksPathExtension
54
+ # Returns schema dump path based on configuration
55
+ #
56
+ # @param db_config [ActiveRecord::DatabaseConfigurations::DatabaseConfig] Database configuration
57
+ # @param format [Symbol] Schema format (:sql or :ruby)
58
+ # @return [Pathname] Path to schema file
59
+ def schema_dump_path(db_config, format = ActiveRecord.schema_format)
60
+ if format.to_sym == :sql && !BetterStructureSql.configuration.output_path.to_s.end_with?('.rb')
61
+ # Return our configured path for SQL format
62
+ Rails.root.join(BetterStructureSql.configuration.output_path)
63
+ else
64
+ # Use default Rails path for Ruby format
65
+ super
66
+ end
67
+ end
68
+ end
69
+
70
+ # Extension to override DatabaseTasks#dump_schema for SQL format
71
+ module DatabaseTasksExtension
72
+ # Dumps schema using BetterStructureSql for SQL format
73
+ #
74
+ # @param db_config [ActiveRecord::DatabaseConfigurations::DatabaseConfig] Database configuration
75
+ # @param format [Symbol] Schema format
76
+ # @return [void]
77
+ def dump_schema(db_config, format = db_config.schema_format)
78
+ # Only override SQL format dumps
79
+ if format.to_sym == :sql
80
+ return unless db_config.schema_dump
81
+
82
+ filename = schema_dump_path(db_config, format)
83
+ return unless filename
84
+
85
+ FileUtils.mkdir_p(File.dirname(filename))
86
+
87
+ # Call our dumper which already includes schema_migrations
88
+ # Don't auto-store version here - use explicit db:schema:store task
89
+ BetterStructureSql::Dumper.new.dump(store_version: false)
90
+ else
91
+ # For Ruby format, call the original method
92
+ super
93
+ end
94
+ end
95
+ end
96
+
97
+ # No longer needed - we override dump_schema instead
98
+ module DatabaseTasksDumpInfoExtension
99
+ # This module is kept for backward compatibility but is no longer used
100
+ end
101
+
102
+ # Extension to override DatabaseTasks#load_schema for multi-file support
103
+ module DatabaseTasksLoadExtension
104
+ # Override load_schema to handle both file and directory schemas
105
+ def load_schema(db_config, format = ActiveRecord.schema_format, *_args)
106
+ if format.to_sym == :sql
107
+ # Get the configured schema path (could be file or directory)
108
+ config = BetterStructureSql.configuration
109
+ schema_path = Rails.root.join(config.output_path)
110
+
111
+ # Check if schema exists (file or directory)
112
+ abort "#{schema_path} doesn't exist yet. Run `bin/rails db:migrate` to create it, then try again." unless File.exist?(schema_path)
113
+
114
+ # Use our loader which handles both file and directory
115
+ loader = BetterStructureSql::SchemaLoader.new(config)
116
+ loader.load
117
+ else
118
+ # For Ruby format, call the original method
119
+ super
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ # Handles loading schema from single file or multi-file directory
5
+ class SchemaLoader
6
+ class LoadError < StandardError; end
7
+
8
+ attr_reader :config
9
+
10
+ def initialize(config = BetterStructureSql.configuration)
11
+ @config = config
12
+ end
13
+
14
+ # Main entry point - auto-detects mode
15
+ # @param path [String, nil] Path to schema file or directory (defaults to config.output_path)
16
+ def load(path = nil)
17
+ path ||= config.output_path
18
+ full_path = Rails.root.join(path)
19
+
20
+ if File.directory?(full_path)
21
+ load_directory(full_path)
22
+ elsif File.file?(full_path)
23
+ load_file(full_path)
24
+ else
25
+ raise LoadError, "Schema path not found: #{full_path}"
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def load_directory(dir_path)
32
+ connection = ActiveRecord::Base.connection
33
+
34
+ # Load header first
35
+ header_path = File.join(dir_path, '_header.sql')
36
+ connection.execute(File.read(header_path)) if File.exist?(header_path)
37
+
38
+ # Load numbered directories in order (01_extensions through 10_migrations)
39
+ # Load all files in each directory and execute statements
40
+ # Use [01]* pattern to match directories starting with 0 or 1 (covers 01-10)
41
+ Dir.glob(File.join(dir_path, '[01]*_*')).sort.each do |dir|
42
+ next unless File.directory?(dir)
43
+
44
+ dir_name = File.basename(dir)
45
+
46
+ # Process files in this directory
47
+ Dir.glob(File.join(dir, '*.sql')).sort.each do |file_path|
48
+ sql_content = File.read(file_path)
49
+ next if sql_content.strip.empty?
50
+
51
+ # Execute SQL (connection.execute can handle multiple statements for SQLite)
52
+ execute_sql_statements(connection, sql_content)
53
+ end
54
+
55
+ Rails.logger.debug { "Loaded #{dir_name}" }
56
+ end
57
+
58
+ # Read manifest for file count (optional, for logging only)
59
+ manifest_path = File.join(dir_path, '_manifest.json')
60
+ return unless File.exist?(manifest_path)
61
+
62
+ manifest = JSON.parse(File.read(manifest_path))
63
+ Rails.logger.debug { "Schema loaded from #{manifest['total_files']} files" }
64
+ end
65
+
66
+ def load_file(file_path)
67
+ # Handle schema.rb vs structure.sql
68
+ if file_path.to_s.end_with?('.rb')
69
+ # Use Rails' ActiveRecord::Tasks::DatabaseTasks for .rb files
70
+ # This is what Rails uses internally for db:schema:load
71
+ ActiveRecord::Tasks::DatabaseTasks.load_schema_current(:ruby, file_path)
72
+ else
73
+ # Execute SQL directly for .sql files
74
+ connection = ActiveRecord::Base.connection
75
+ sql = File.read(file_path)
76
+ execute_sql_statements(connection, sql)
77
+ end
78
+
79
+ Rails.logger.debug { "Schema loaded from #{file_path}" }
80
+ end
81
+
82
+ # Execute SQL statements, handling adapter-specific multi-statement behavior
83
+ def execute_sql_statements(connection, sql)
84
+ adapter_name = connection.adapter_name.downcase
85
+
86
+ case adapter_name
87
+ when 'sqlite'
88
+ # SQLite's ActiveRecord execute() uses prepare() which can't handle multiple statements
89
+ # We need to use the raw database connection's execute_batch method
90
+ connection.raw_connection.execute_batch(sql)
91
+ when 'postgresql', 'postgis'
92
+ # PostgreSQL can handle multiple statements in one execute call
93
+ connection.execute(sql)
94
+ when 'mysql', 'mysql2', 'trilogy'
95
+ # MySQL needs statements executed individually
96
+ execute_mysql_statements(connection, sql)
97
+ else
98
+ # Fallback: Try executing as-is first
99
+ begin
100
+ connection.execute(sql)
101
+ rescue StandardError
102
+ # If that fails, split by semicolon and execute individually
103
+ sql.split(/;\\s*$/).each do |statement|
104
+ next if statement.strip.empty?
105
+
106
+ connection.execute("#{statement};")
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ def execute_mysql_statements(connection, sql)
113
+ # Split SQL into individual statements for MySQL
114
+ # MySQL can't execute multiple statements in one call via ActiveRecord normally.
115
+ # For procedures/triggers with BEGIN/END blocks, we need special handling.
116
+
117
+ # Split into statements, respecting BEGIN/END blocks
118
+ statements = []
119
+ current_statement = +'' # Unfreeze string with unary plus
120
+ in_block = false # Track if we're inside a BEGIN/END block
121
+
122
+ sql.each_line do |line|
123
+ # Skip standalone comment lines
124
+ next if line.strip.start_with?('--') && current_statement.strip.empty?
125
+
126
+ current_statement << line
127
+
128
+ # Track BEGIN/END blocks (procedures and triggers)
129
+ # MySQL procedures: CREATE PROCEDURE name(...) BEGIN ... END;
130
+ # MySQL triggers: CREATE TRIGGER ... FOR EACH ROW BEGIN ... END;
131
+ stripped_line = line.strip.upcase
132
+
133
+ # Detect start of block: "BEGIN" on its own line or "FOR EACH ROW BEGIN"
134
+ in_block = true if stripped_line == 'BEGIN' || stripped_line.end_with?(' BEGIN')
135
+
136
+ # Detect end of block: "END" or "END;"
137
+ in_block = false if ['END;', 'END'].include?(stripped_line)
138
+
139
+ # Statement is complete when:
140
+ # 1. Line ends with semicolon, AND
141
+ # 2. We just closed a block (ended with END or END;), OR we're not in a block
142
+ if line.strip.end_with?(';') && !in_block
143
+ statements << current_statement.strip
144
+ current_statement = +'' # Unfreeze new string
145
+ end
146
+ end
147
+
148
+ # Add any remaining statement
149
+ statements << current_statement.strip unless current_statement.strip.empty?
150
+
151
+ # Execute each statement using raw connection
152
+ statements.each_with_index do |statement, index|
153
+ next if statement.empty?
154
+ next if statement.start_with?('--') # Skip standalone comments
155
+
156
+ begin
157
+ # Use ActiveRecord connection execute which works for procedures/triggers
158
+ connection.execute(statement)
159
+ rescue StandardError => e
160
+ # Log helpful error with statement number
161
+ Rails.logger.error "Failed to execute MySQL statement #{index + 1}/#{statements.length}: #{e.message}"
162
+ Rails.logger.error "Statement: #{statement[0..200]}"
163
+ raise
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ # ActiveRecord model for stored schema versions
5
+ #
6
+ # Stores schema snapshots with metadata for versioning, comparison,
7
+ # and restoration. Supports both single-file and multi-file formats
8
+ # with optional ZIP archive storage.
9
+ class SchemaVersion < ActiveRecord::Base
10
+ self.table_name = 'better_structure_sql_schema_versions'
11
+
12
+ # Callbacks
13
+ before_save :set_metadata
14
+
15
+ # Validations
16
+ validates :content, presence: true
17
+ validates :pg_version, presence: true
18
+ validates :format_type, presence: true, inclusion: { in: %w[sql rb] }
19
+ validates :output_mode, presence: true, inclusion: { in: %w[single_file multi_file] }
20
+
21
+ # Scopes
22
+ scope :latest, -> { order(created_at: :desc).first }
23
+ scope :by_format, ->(type) { where(format_type: type) }
24
+ scope :by_output_mode, ->(mode) { where(output_mode: mode) }
25
+ scope :recent, ->(limit) { order(created_at: :desc).limit(limit) }
26
+ scope :oldest_first, -> { order(created_at: :asc) }
27
+
28
+ # Instance methods
29
+ def size
30
+ # Use stored content_size if available and content hasn't changed
31
+ if content_size.present? && !content_changed?
32
+ content_size
33
+ else
34
+ content.bytesize
35
+ end
36
+ end
37
+
38
+ # Returns human-readable size string
39
+ #
40
+ # @return [String] Formatted size (e.g., "1.5 MB", "250 KB")
41
+ def formatted_size
42
+ bytes = size
43
+ if bytes < 1024
44
+ "#{bytes} bytes"
45
+ elsif bytes < 1024 * 1024
46
+ "#{(bytes / 1024.0).round(2)} KB"
47
+ else
48
+ "#{(bytes / 1024.0 / 1024.0).round(2)} MB"
49
+ end
50
+ end
51
+
52
+ # Checks if this version uses multi-file format
53
+ #
54
+ # @return [Boolean] True if multi-file format
55
+ def multi_file?
56
+ output_mode == 'multi_file'
57
+ end
58
+
59
+ # Checks if this version has a ZIP archive
60
+ #
61
+ # @return [Boolean] True if ZIP archive exists
62
+ def zip_archive?
63
+ zip_archive.present?
64
+ end
65
+
66
+ # Extracts ZIP archive to target directory
67
+ #
68
+ # @param target_dir [String, Pathname] Target directory path
69
+ # @return [String, nil] Target directory path or nil if no archive
70
+ def extract_zip_to_directory(target_dir)
71
+ return nil unless zip_archive?
72
+
73
+ ZipGenerator.extract_to_directory(zip_archive, target_dir)
74
+ target_dir
75
+ end
76
+
77
+ private
78
+
79
+ def set_metadata
80
+ return unless content_changed?
81
+
82
+ self.content_size = content.bytesize
83
+ self.line_count = content.lines.count
84
+ end
85
+ end
86
+ end