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,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
|