better_structure_sql 0.1.0 → 0.2.1
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 +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +240 -31
- data/app/controllers/better_structure_sql/schema_versions_controller.rb +5 -4
- data/app/views/better_structure_sql/schema_versions/index.html.erb +6 -0
- data/app/views/better_structure_sql/schema_versions/show.html.erb +13 -1
- data/lib/better_structure_sql/adapters/base_adapter.rb +18 -0
- data/lib/better_structure_sql/adapters/mysql_adapter.rb +199 -4
- data/lib/better_structure_sql/adapters/postgresql_adapter.rb +321 -37
- data/lib/better_structure_sql/adapters/sqlite_adapter.rb +218 -59
- data/lib/better_structure_sql/configuration.rb +12 -10
- data/lib/better_structure_sql/dumper.rb +230 -102
- data/lib/better_structure_sql/errors.rb +24 -0
- data/lib/better_structure_sql/file_writer.rb +2 -1
- data/lib/better_structure_sql/generators/base.rb +38 -0
- data/lib/better_structure_sql/generators/comment_generator.rb +118 -0
- data/lib/better_structure_sql/generators/domain_generator.rb +2 -1
- data/lib/better_structure_sql/generators/index_generator.rb +3 -1
- data/lib/better_structure_sql/generators/table_generator.rb +45 -20
- data/lib/better_structure_sql/generators/type_generator.rb +5 -3
- data/lib/better_structure_sql/schema_loader.rb +3 -3
- data/lib/better_structure_sql/schema_version.rb +17 -1
- data/lib/better_structure_sql/schema_versions.rb +223 -20
- data/lib/better_structure_sql/store_result.rb +46 -0
- data/lib/better_structure_sql/version.rb +1 -1
- data/lib/better_structure_sql.rb +4 -1
- data/lib/generators/better_structure_sql/templates/README +1 -1
- data/lib/generators/better_structure_sql/templates/migration.rb.erb +2 -0
- data/lib/tasks/better_structure_sql.rake +35 -18
- metadata +4 -2
- data/lib/generators/better_structure_sql/templates/add_metadata_migration.rb.erb +0 -25
|
@@ -20,33 +20,58 @@ module BetterStructureSql
|
|
|
20
20
|
# @return [String] SQL statement
|
|
21
21
|
def generate(table)
|
|
22
22
|
lines = ["CREATE TABLE IF NOT EXISTS #{table[:name]} ("]
|
|
23
|
+
lines << build_table_contents(table)
|
|
24
|
+
lines << ');'
|
|
25
|
+
lines.join("\n")
|
|
26
|
+
end
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if table[:primary_key]&.any?
|
|
27
|
-
# Quote primary key column names
|
|
28
|
-
pk_cols = table[:primary_key].map { |col| quote_column_name(col) }.join(', ')
|
|
29
|
-
column_defs << "PRIMARY KEY (#{pk_cols})"
|
|
30
|
-
end
|
|
28
|
+
private
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
# Build table contents (columns, primary key, constraints, foreign keys)
|
|
31
|
+
#
|
|
32
|
+
# @param table [Hash] Table metadata
|
|
33
|
+
# @return [String] Formatted table contents
|
|
34
|
+
def build_table_contents(table)
|
|
35
|
+
column_defs = build_column_definitions(table)
|
|
36
|
+
column_defs << build_primary_key_clause(table) if table[:primary_key]&.any?
|
|
37
|
+
column_defs.concat(build_constraints_clauses(table))
|
|
38
|
+
column_defs.concat(build_foreign_keys_clauses(table)) if sqlite_adapter? && table[:foreign_keys]&.any?
|
|
39
|
+
|
|
40
|
+
column_defs.map { |def_line| indent(def_line) }.join(",\n")
|
|
41
|
+
end
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
# Build column definitions
|
|
44
|
+
#
|
|
45
|
+
# @param table [Hash] Table metadata
|
|
46
|
+
# @return [Array<String>] Array of column definition strings
|
|
47
|
+
def build_column_definitions(table)
|
|
48
|
+
table[:columns].map { |col| column_definition(col) }
|
|
49
|
+
end
|
|
42
50
|
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
# Build primary key clause
|
|
52
|
+
#
|
|
53
|
+
# @param table [Hash] Table metadata
|
|
54
|
+
# @return [String] PRIMARY KEY clause
|
|
55
|
+
def build_primary_key_clause(table)
|
|
56
|
+
pk_cols = table[:primary_key].map { |col| quote_column_name(col) }.join(', ')
|
|
57
|
+
"PRIMARY KEY (#{pk_cols})"
|
|
58
|
+
end
|
|
45
59
|
|
|
46
|
-
|
|
60
|
+
# Build constraints clauses
|
|
61
|
+
#
|
|
62
|
+
# @param table [Hash] Table metadata
|
|
63
|
+
# @return [Array<String>] Array of constraint definition strings
|
|
64
|
+
def build_constraints_clauses(table)
|
|
65
|
+
table[:constraints]&.map { |constraint| constraint_definition(constraint) } || []
|
|
47
66
|
end
|
|
48
67
|
|
|
49
|
-
|
|
68
|
+
# Build foreign keys clauses for SQLite
|
|
69
|
+
#
|
|
70
|
+
# @param table [Hash] Table metadata
|
|
71
|
+
# @return [Array<String>] Array of foreign key definition strings
|
|
72
|
+
def build_foreign_keys_clauses(table)
|
|
73
|
+
table[:foreign_keys].map { |fk| foreign_key_definition(fk) }
|
|
74
|
+
end
|
|
50
75
|
|
|
51
76
|
def column_definition(column)
|
|
52
77
|
# Quote column name for MySQL compatibility (reserved words like 'key')
|
|
@@ -26,19 +26,21 @@ module BetterStructureSql
|
|
|
26
26
|
|
|
27
27
|
def generate_enum(type)
|
|
28
28
|
values = type[:values].map { |v| "'#{v}'" }.join(', ')
|
|
29
|
-
"CREATE TYPE
|
|
29
|
+
"CREATE TYPE #{type[:name]} AS ENUM (#{values});"
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def generate_composite(type)
|
|
33
33
|
# Composite types have attributes
|
|
34
|
+
# Note: PostgreSQL does not support IF NOT EXISTS for composite types
|
|
34
35
|
attrs = type[:attributes].map do |attr|
|
|
35
36
|
"#{attr[:name]} #{attr[:type]}"
|
|
36
37
|
end.join(', ')
|
|
37
|
-
"CREATE TYPE
|
|
38
|
+
"CREATE TYPE #{type[:name]} AS (#{attrs});"
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
def generate_domain(type)
|
|
41
|
-
|
|
42
|
+
# NOTE: PostgreSQL does not support IF NOT EXISTS for domains
|
|
43
|
+
parts = ["CREATE DOMAIN #{type[:name]} AS #{type[:base_type]}"]
|
|
42
44
|
parts << type[:constraint] if type[:constraint]
|
|
43
45
|
"#{parts.join(' ')};"
|
|
44
46
|
end
|
|
@@ -35,10 +35,10 @@ module BetterStructureSql
|
|
|
35
35
|
header_path = File.join(dir_path, '_header.sql')
|
|
36
36
|
connection.execute(File.read(header_path)) if File.exist?(header_path)
|
|
37
37
|
|
|
38
|
-
# Load numbered directories in order (01_extensions through
|
|
38
|
+
# Load numbered directories in order (01_extensions through 20_migrations)
|
|
39
39
|
# Load all files in each directory and execute statements
|
|
40
|
-
# Use [
|
|
41
|
-
Dir.glob(File.join(dir_path, '[
|
|
40
|
+
# Use [012]* pattern to match directories starting with 0, 1, or 2 (covers 01-29)
|
|
41
|
+
Dir.glob(File.join(dir_path, '[012]*_*')).sort.each do |dir|
|
|
42
42
|
next unless File.directory?(dir)
|
|
43
43
|
|
|
44
44
|
dir_name = File.basename(dir)
|
|
@@ -14,17 +14,25 @@ module BetterStructureSql
|
|
|
14
14
|
|
|
15
15
|
# Validations
|
|
16
16
|
validates :content, presence: true
|
|
17
|
+
validates :content_hash, presence: true,
|
|
18
|
+
format: { with: /\A[a-f0-9]{32}\z/,
|
|
19
|
+
message: 'must be 32-character MD5 hex digest' }
|
|
17
20
|
validates :pg_version, presence: true
|
|
18
21
|
validates :format_type, presence: true, inclusion: { in: %w[sql rb] }
|
|
19
22
|
validates :output_mode, presence: true, inclusion: { in: %w[single_file multi_file] }
|
|
20
23
|
|
|
21
24
|
# Scopes
|
|
22
|
-
scope :
|
|
25
|
+
scope :latest_first, -> { order(created_at: :desc) }
|
|
23
26
|
scope :by_format, ->(type) { where(format_type: type) }
|
|
24
27
|
scope :by_output_mode, ->(mode) { where(output_mode: mode) }
|
|
25
28
|
scope :recent, ->(limit) { order(created_at: :desc).limit(limit) }
|
|
26
29
|
scope :oldest_first, -> { order(created_at: :asc) }
|
|
27
30
|
|
|
31
|
+
# Class method to get latest version
|
|
32
|
+
def self.latest
|
|
33
|
+
order(created_at: :desc).first
|
|
34
|
+
end
|
|
35
|
+
|
|
28
36
|
# Instance methods
|
|
29
37
|
def size
|
|
30
38
|
# Use stored content_size if available and content hasn't changed
|
|
@@ -63,6 +71,14 @@ module BetterStructureSql
|
|
|
63
71
|
zip_archive.present?
|
|
64
72
|
end
|
|
65
73
|
|
|
74
|
+
# Compares this version's hash with another hash
|
|
75
|
+
#
|
|
76
|
+
# @param other_hash [String] Hash to compare against
|
|
77
|
+
# @return [Boolean] True if hashes match
|
|
78
|
+
def hash_matches?(other_hash)
|
|
79
|
+
content_hash == other_hash
|
|
80
|
+
end
|
|
81
|
+
|
|
66
82
|
# Extracts ZIP archive to target directory
|
|
67
83
|
#
|
|
68
84
|
# @param target_dir [String, Pathname] Target directory path
|
|
@@ -6,8 +6,19 @@ module BetterStructureSql
|
|
|
6
6
|
# Provides class methods for storing, querying, and managing
|
|
7
7
|
# schema versions with automatic retention cleanup.
|
|
8
8
|
module SchemaVersions
|
|
9
|
+
# Chunk size for streaming file reads (4MB)
|
|
10
|
+
# Balances memory efficiency with read performance
|
|
11
|
+
STREAM_CHUNK_SIZE = 4 * 1024 * 1024
|
|
12
|
+
|
|
9
13
|
class << self
|
|
10
|
-
# Store current schema from file
|
|
14
|
+
# Store current schema from file with automatic deduplication
|
|
15
|
+
#
|
|
16
|
+
# Calculates content hash and compares with latest version.
|
|
17
|
+
# Skips storage if hash matches (no schema changes).
|
|
18
|
+
# Cleans up filesystem directory after storing ZIP (multi-file mode only).
|
|
19
|
+
#
|
|
20
|
+
# @param connection [ActiveRecord::Connection] Database connection
|
|
21
|
+
# @return [StoreResult] Result indicating stored or skipped
|
|
11
22
|
def store_current(connection = ActiveRecord::Base.connection)
|
|
12
23
|
config = BetterStructureSql.configuration
|
|
13
24
|
pg_version = PgVersion.detect(connection)
|
|
@@ -16,13 +27,24 @@ module BetterStructureSql
|
|
|
16
27
|
format_type = deduce_format_type(config.output_path)
|
|
17
28
|
output_mode = detect_output_mode(config.output_path)
|
|
18
29
|
|
|
19
|
-
# Read content
|
|
30
|
+
# Read content and calculate hash
|
|
20
31
|
content, zip_archive, file_count = read_schema_content(config.output_path, output_mode)
|
|
32
|
+
return build_skip_result(nil, count) unless content
|
|
21
33
|
|
|
22
|
-
|
|
34
|
+
content_hash = calculate_hash(content)
|
|
35
|
+
|
|
36
|
+
# Compare with latest version's hash
|
|
37
|
+
ensure_table_exists!(connection)
|
|
38
|
+
latest_version = latest
|
|
39
|
+
if latest_version && latest_version.content_hash == content_hash
|
|
40
|
+
# Skip storage - no changes detected (directory remains for re-use)
|
|
41
|
+
return build_skip_result(latest_version, count)
|
|
42
|
+
end
|
|
23
43
|
|
|
24
|
-
|
|
44
|
+
# Proceed with storage - hash differs or no previous versions
|
|
45
|
+
version = store(
|
|
25
46
|
content: content,
|
|
47
|
+
content_hash: content_hash,
|
|
26
48
|
zip_archive: zip_archive,
|
|
27
49
|
format_type: format_type,
|
|
28
50
|
output_mode: output_mode,
|
|
@@ -30,10 +52,26 @@ module BetterStructureSql
|
|
|
30
52
|
file_count: file_count,
|
|
31
53
|
connection: connection
|
|
32
54
|
)
|
|
55
|
+
|
|
56
|
+
# Cleanup filesystem directory after ZIP stored (multi-file mode only)
|
|
57
|
+
cleanup_filesystem_directory(config.output_path, output_mode)
|
|
58
|
+
|
|
59
|
+
# Cleanup old versions (retention management)
|
|
60
|
+
cleanup!(connection)
|
|
61
|
+
|
|
62
|
+
# Return stored result
|
|
63
|
+
build_stored_result(version)
|
|
33
64
|
end
|
|
34
65
|
|
|
35
66
|
# Store schema version with explicit parameters
|
|
36
|
-
|
|
67
|
+
#
|
|
68
|
+
# @param content [String] Schema content
|
|
69
|
+
# @param content_hash [String] MD5 hash of content (required)
|
|
70
|
+
# @param format_type [String] Format type ('sql' or 'rb')
|
|
71
|
+
# @param pg_version [String] PostgreSQL version
|
|
72
|
+
# @param options [Hash] Optional parameters (connection, output_mode, zip_archive, file_count)
|
|
73
|
+
# @return [SchemaVersion] Created version
|
|
74
|
+
def store(content:, content_hash:, format_type:, pg_version:, **options)
|
|
37
75
|
connection = options.fetch(:connection, ActiveRecord::Base.connection)
|
|
38
76
|
output_mode = options.fetch(:output_mode, 'single_file')
|
|
39
77
|
zip_archive = options[:zip_archive]
|
|
@@ -41,8 +79,11 @@ module BetterStructureSql
|
|
|
41
79
|
|
|
42
80
|
ensure_table_exists!(connection)
|
|
43
81
|
|
|
44
|
-
|
|
82
|
+
SchemaVersion.create!(
|
|
45
83
|
content: content,
|
|
84
|
+
content_hash: content_hash,
|
|
85
|
+
content_size: content.bytesize,
|
|
86
|
+
line_count: content.count("\n"),
|
|
46
87
|
zip_archive: zip_archive,
|
|
47
88
|
format_type: format_type,
|
|
48
89
|
output_mode: output_mode,
|
|
@@ -50,10 +91,6 @@ module BetterStructureSql
|
|
|
50
91
|
file_count: file_count,
|
|
51
92
|
created_at: Time.current
|
|
52
93
|
)
|
|
53
|
-
|
|
54
|
-
cleanup!(connection)
|
|
55
|
-
|
|
56
|
-
version
|
|
57
94
|
end
|
|
58
95
|
|
|
59
96
|
# Retrieval methods
|
|
@@ -101,6 +138,121 @@ module BetterStructureSql
|
|
|
101
138
|
SchemaVersion.by_format(format_type).order(created_at: :desc).to_a
|
|
102
139
|
end
|
|
103
140
|
|
|
141
|
+
# Hash calculation methods
|
|
142
|
+
|
|
143
|
+
# Calculates MD5 hash of schema content
|
|
144
|
+
#
|
|
145
|
+
# @param content [String] Schema content to hash
|
|
146
|
+
# @return [String] 32-character MD5 hexdigest
|
|
147
|
+
# @raise [ArgumentError] If content is nil
|
|
148
|
+
def calculate_hash(content)
|
|
149
|
+
raise ArgumentError, 'Content cannot be nil' if content.nil?
|
|
150
|
+
|
|
151
|
+
require 'digest/md5'
|
|
152
|
+
Digest::MD5.hexdigest(content)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Calculates hash from a single schema file using streaming
|
|
156
|
+
#
|
|
157
|
+
# Streams file in 4MB chunks to avoid loading entire file into memory.
|
|
158
|
+
# Efficient for large schema files (100MB+).
|
|
159
|
+
#
|
|
160
|
+
# @param path [String, Pathname] Path to schema file
|
|
161
|
+
# @return [String] 32-character MD5 hexdigest
|
|
162
|
+
# @raise [Errno::ENOENT] If file not found
|
|
163
|
+
def calculate_hash_from_file(path)
|
|
164
|
+
full_path = Rails.root.join(path)
|
|
165
|
+
raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(full_path)
|
|
166
|
+
|
|
167
|
+
digest = Digest::MD5.new
|
|
168
|
+
File.open(full_path, 'rb') do |file|
|
|
169
|
+
while (chunk = file.read(STREAM_CHUNK_SIZE))
|
|
170
|
+
digest.update(chunk)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
digest.hexdigest
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Calculates hash from multi-file schema directory using streaming
|
|
177
|
+
#
|
|
178
|
+
# Streams only .sql files (header + numbered directories) in order.
|
|
179
|
+
# Uses 4MB chunks for memory efficiency with large schemas.
|
|
180
|
+
#
|
|
181
|
+
# Note: Manifest JSON is excluded from hash calculation as it's metadata
|
|
182
|
+
# for tooling, not part of the actual schema content.
|
|
183
|
+
#
|
|
184
|
+
# @param path [String, Pathname] Path to schema directory
|
|
185
|
+
# @return [String] 32-character MD5 hexdigest
|
|
186
|
+
# @raise [Errno::ENOENT] If directory not found
|
|
187
|
+
def calculate_hash_from_directory(path)
|
|
188
|
+
full_path = Rails.root.join(path)
|
|
189
|
+
raise Errno::ENOENT, "Directory not found: #{path}" unless Dir.exist?(full_path)
|
|
190
|
+
|
|
191
|
+
digest = Digest::MD5.new
|
|
192
|
+
|
|
193
|
+
# Stream _header.sql if exists
|
|
194
|
+
header_path = full_path.join('_header.sql')
|
|
195
|
+
stream_file_to_digest(digest, header_path) if File.exist?(header_path)
|
|
196
|
+
|
|
197
|
+
# Stream all .sql files from numbered directories in order (01_ through 10_)
|
|
198
|
+
Dir.glob(File.join(full_path, '*_*')).select { |f| File.directory?(f) }.sort.each do |dir|
|
|
199
|
+
Dir.glob(File.join(dir, '*.sql')).sort.each do |file|
|
|
200
|
+
stream_file_to_digest(digest, file)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
digest.hexdigest
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Combined read and hash operation
|
|
208
|
+
#
|
|
209
|
+
# @param output_path [String, Pathname] Path to schema file or directory
|
|
210
|
+
# @param output_mode [String] 'single_file' or 'multi_file'
|
|
211
|
+
# @return [Array<String, String, String, Integer>] content, content_hash, zip_archive, file_count
|
|
212
|
+
def read_and_hash_content(output_path, output_mode)
|
|
213
|
+
content, zip_archive, file_count = read_schema_content(output_path, output_mode)
|
|
214
|
+
return [nil, nil, nil, nil] unless content
|
|
215
|
+
|
|
216
|
+
content_hash = calculate_hash(content)
|
|
217
|
+
[content, content_hash, zip_archive, file_count]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Hash query methods
|
|
221
|
+
|
|
222
|
+
# Retrieves content_hash of the most recent schema version
|
|
223
|
+
#
|
|
224
|
+
# @param _connection [ActiveRecord::Connection] Database connection (optional)
|
|
225
|
+
# @return [String, nil] Hash of latest version or nil if no versions exist
|
|
226
|
+
def latest_hash(_connection = ActiveRecord::Base.connection)
|
|
227
|
+
return nil unless table_exists?
|
|
228
|
+
|
|
229
|
+
SchemaVersion.latest&.content_hash
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Checks if a content hash exists in stored versions
|
|
233
|
+
#
|
|
234
|
+
# @param hash [String] Content hash to search for
|
|
235
|
+
# @param _connection [ActiveRecord::Connection] Database connection (optional)
|
|
236
|
+
# @return [Boolean] True if hash found, false otherwise
|
|
237
|
+
def hash_exists?(hash, _connection = ActiveRecord::Base.connection)
|
|
238
|
+
return false unless table_exists?
|
|
239
|
+
|
|
240
|
+
SchemaVersion.exists?(content_hash: hash)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Finds schema version by content hash
|
|
244
|
+
#
|
|
245
|
+
# Returns most recent version if multiple versions have the same hash.
|
|
246
|
+
#
|
|
247
|
+
# @param hash [String] Content hash to search for
|
|
248
|
+
# @param _connection [ActiveRecord::Connection] Database connection (optional)
|
|
249
|
+
# @return [SchemaVersion, nil] Found version or nil
|
|
250
|
+
def find_by_hash(hash, _connection = ActiveRecord::Base.connection)
|
|
251
|
+
return nil unless table_exists?
|
|
252
|
+
|
|
253
|
+
SchemaVersion.where(content_hash: hash).order(created_at: :desc).first
|
|
254
|
+
end
|
|
255
|
+
|
|
104
256
|
# Retention management
|
|
105
257
|
def cleanup!(_connection = ActiveRecord::Base.connection)
|
|
106
258
|
return 0 unless table_exists?
|
|
@@ -129,6 +281,64 @@ module BetterStructureSql
|
|
|
129
281
|
|
|
130
282
|
private
|
|
131
283
|
|
|
284
|
+
# Builds a skip result when storage is not needed
|
|
285
|
+
#
|
|
286
|
+
# @param version [SchemaVersion, nil] Existing version that matched
|
|
287
|
+
# @param total_count [Integer] Total version count
|
|
288
|
+
# @return [StoreResult] Skip result
|
|
289
|
+
def build_skip_result(version, total_count)
|
|
290
|
+
StoreResult.new(
|
|
291
|
+
skipped: true,
|
|
292
|
+
version_id: version&.id,
|
|
293
|
+
hash: version&.content_hash,
|
|
294
|
+
total_count: total_count
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Builds a stored result when new version was created
|
|
299
|
+
#
|
|
300
|
+
# @param version [SchemaVersion] Newly created version
|
|
301
|
+
# @return [StoreResult] Stored result
|
|
302
|
+
def build_stored_result(version)
|
|
303
|
+
StoreResult.new(
|
|
304
|
+
skipped: false,
|
|
305
|
+
version: version,
|
|
306
|
+
total_count: count
|
|
307
|
+
)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Cleanup filesystem directory after ZIP archive stored
|
|
311
|
+
#
|
|
312
|
+
# Only removes multi-file directories (single files remain).
|
|
313
|
+
# Gracefully handles errors without failing the storage operation.
|
|
314
|
+
#
|
|
315
|
+
# @param output_path [String, Pathname] Path to directory
|
|
316
|
+
# @param output_mode [String] Output mode ('single_file' or 'multi_file')
|
|
317
|
+
def cleanup_filesystem_directory(output_path, output_mode)
|
|
318
|
+
# Only cleanup multi-file directories (single files remain)
|
|
319
|
+
return unless output_mode == 'multi_file'
|
|
320
|
+
|
|
321
|
+
full_path = Rails.root.join(output_path)
|
|
322
|
+
return unless Dir.exist?(full_path)
|
|
323
|
+
|
|
324
|
+
FileUtils.rm_rf(full_path)
|
|
325
|
+
rescue StandardError => e
|
|
326
|
+
warn "Warning: Failed to cleanup directory #{output_path}: #{e.message}"
|
|
327
|
+
# Continue despite cleanup failure - version already stored
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Streams a file to digest using STREAM_CHUNK_SIZE chunks
|
|
331
|
+
#
|
|
332
|
+
# @param digest [Digest::MD5] Digest instance to update
|
|
333
|
+
# @param file_path [String, Pathname] Path to file
|
|
334
|
+
def stream_file_to_digest(digest, file_path)
|
|
335
|
+
File.open(file_path, 'rb') do |file|
|
|
336
|
+
while (chunk = file.read(STREAM_CHUNK_SIZE))
|
|
337
|
+
digest.update(chunk)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
132
342
|
def read_schema_content(output_path, output_mode)
|
|
133
343
|
case output_mode
|
|
134
344
|
when 'single_file'
|
|
@@ -158,19 +368,12 @@ module BetterStructureSql
|
|
|
158
368
|
def read_multi_file_content(base_path)
|
|
159
369
|
content_parts = []
|
|
160
370
|
|
|
161
|
-
# Read
|
|
371
|
+
# Read _header.sql if exists
|
|
162
372
|
header_path = base_path.join('_header.sql')
|
|
163
373
|
content_parts << File.read(header_path) if File.exist?(header_path)
|
|
164
374
|
|
|
165
|
-
# Read
|
|
166
|
-
|
|
167
|
-
if File.exist?(manifest_path)
|
|
168
|
-
manifest_json = File.read(manifest_path)
|
|
169
|
-
content_parts << "-- MANIFEST_JSON_START\n-- #{manifest_json.gsub("\n", "\n-- ")}\n-- MANIFEST_JSON_END"
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
# Read numbered directories in order (01_ through 10_)
|
|
173
|
-
# Use pattern that works with Dir.glob
|
|
375
|
+
# Read all .sql files from numbered directories in order (01_ through 10_)
|
|
376
|
+
# Note: Manifest JSON is excluded as it's metadata, not schema content
|
|
174
377
|
Dir.glob(File.join(base_path, '*_*')).select { |f| File.directory?(f) }.sort.each do |dir|
|
|
175
378
|
Dir.glob(File.join(dir, '*.sql')).sort.each do |file|
|
|
176
379
|
content_parts << File.read(file)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterStructureSql
|
|
4
|
+
# Value object encapsulating schema version storage operation result
|
|
5
|
+
#
|
|
6
|
+
# Provides clean separation between storage logic and output formatting.
|
|
7
|
+
# Used by SchemaVersions.store_current to indicate whether storage occurred
|
|
8
|
+
# or was skipped due to duplicate content hash.
|
|
9
|
+
#
|
|
10
|
+
# @example Stored result
|
|
11
|
+
# version = SchemaVersion.create!(content: "...", content_hash: "abc123")
|
|
12
|
+
# result = StoreResult.new(skipped: false, version: version)
|
|
13
|
+
# result.stored? # => true
|
|
14
|
+
# result.version_id # => 5
|
|
15
|
+
#
|
|
16
|
+
# @example Skipped result
|
|
17
|
+
# result = StoreResult.new(skipped: true, version_id: 3, hash: "abc123")
|
|
18
|
+
# result.skipped? # => true
|
|
19
|
+
# result.version_id # => 3
|
|
20
|
+
class StoreResult
|
|
21
|
+
attr_reader :version, :version_id, :hash, :total_count
|
|
22
|
+
|
|
23
|
+
# @param skipped [Boolean] Whether storage was skipped
|
|
24
|
+
# @param version [SchemaVersion, nil] Created version (when stored)
|
|
25
|
+
# @param version_id [Integer, nil] Existing version ID (when skipped)
|
|
26
|
+
# @param hash [String, nil] Content hash
|
|
27
|
+
# @param total_count [Integer, nil] Total version count after operation
|
|
28
|
+
def initialize(skipped:, version: nil, version_id: nil, hash: nil, total_count: nil)
|
|
29
|
+
@skipped = skipped
|
|
30
|
+
@version = version
|
|
31
|
+
@version_id = version_id || version&.id
|
|
32
|
+
@hash = hash || version&.content_hash
|
|
33
|
+
@total_count = total_count
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Boolean] True if storage was skipped due to duplicate hash
|
|
37
|
+
def skipped?
|
|
38
|
+
@skipped
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Boolean] True if new version was stored
|
|
42
|
+
def stored?
|
|
43
|
+
!@skipped
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/better_structure_sql.rb
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require 'active_record'
|
|
4
4
|
require_relative 'better_structure_sql/version'
|
|
5
|
+
require_relative 'better_structure_sql/errors'
|
|
5
6
|
require_relative 'better_structure_sql/adapters/base_adapter'
|
|
6
7
|
require_relative 'better_structure_sql/adapters/postgresql_config'
|
|
7
8
|
require_relative 'better_structure_sql/adapters/registry'
|
|
8
9
|
require_relative 'better_structure_sql/configuration'
|
|
9
|
-
require_relative 'better_structure_sql/dependency_resolver'
|
|
10
|
+
# require_relative 'better_structure_sql/dependency_resolver' # TODO: Not yet integrated, implement in future phase
|
|
10
11
|
require_relative 'better_structure_sql/introspection'
|
|
11
12
|
require_relative 'better_structure_sql/formatter'
|
|
12
13
|
require_relative 'better_structure_sql/generators/base'
|
|
@@ -21,9 +22,11 @@ require_relative 'better_structure_sql/generators/materialized_view_generator'
|
|
|
21
22
|
require_relative 'better_structure_sql/generators/function_generator'
|
|
22
23
|
require_relative 'better_structure_sql/generators/trigger_generator'
|
|
23
24
|
require_relative 'better_structure_sql/generators/domain_generator'
|
|
25
|
+
require_relative 'better_structure_sql/generators/comment_generator'
|
|
24
26
|
require_relative 'better_structure_sql/database_version'
|
|
25
27
|
require_relative 'better_structure_sql/pg_version'
|
|
26
28
|
require_relative 'better_structure_sql/schema_version'
|
|
29
|
+
require_relative 'better_structure_sql/store_result'
|
|
27
30
|
require_relative 'better_structure_sql/schema_versions'
|
|
28
31
|
require_relative 'better_structure_sql/file_writer'
|
|
29
32
|
require_relative 'better_structure_sql/manifest_generator'
|
|
@@ -44,6 +44,6 @@ Coming in Phase 3:
|
|
|
44
44
|
- Advanced PostgreSQL features
|
|
45
45
|
|
|
46
46
|
For more information, visit:
|
|
47
|
-
https://github.com/
|
|
47
|
+
https://github.com/sebyx07/better_structure_sql
|
|
48
48
|
|
|
49
49
|
===============================================================================
|
|
@@ -2,6 +2,7 @@ class CreateBetterStructureSqlSchemaVersions < ActiveRecord::Migration<%= migrat
|
|
|
2
2
|
def change
|
|
3
3
|
create_table :better_structure_sql_schema_versions do |t|
|
|
4
4
|
t.text :content, null: false
|
|
5
|
+
t.string :content_hash, limit: 32, null: false
|
|
5
6
|
t.binary :zip_archive, null: true
|
|
6
7
|
t.string :pg_version, null: false
|
|
7
8
|
t.string :format_type, null: false
|
|
@@ -13,6 +14,7 @@ class CreateBetterStructureSqlSchemaVersions < ActiveRecord::Migration<%= migrat
|
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
add_index :better_structure_sql_schema_versions, :created_at, order: { created_at: :desc }
|
|
17
|
+
add_index :better_structure_sql_schema_versions, :content_hash
|
|
16
18
|
add_index :better_structure_sql_schema_versions, :output_mode
|
|
17
19
|
|
|
18
20
|
add_check_constraint :better_structure_sql_schema_versions,
|
|
@@ -73,53 +73,70 @@ namespace :db do
|
|
|
73
73
|
task store: :environment do
|
|
74
74
|
require 'better_structure_sql'
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
config = BetterStructureSql.configuration
|
|
77
|
+
unless config.enable_schema_versions
|
|
77
78
|
puts 'Schema versioning is not enabled.'
|
|
78
79
|
puts 'Enable it in config/initializers/better_structure_sql.rb:'
|
|
79
80
|
puts ' config.enable_schema_versions = true'
|
|
80
|
-
|
|
81
|
+
next
|
|
81
82
|
end
|
|
82
83
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
puts "
|
|
84
|
+
connection = ActiveRecord::Base.connection
|
|
85
|
+
result = BetterStructureSql::SchemaVersions.store_current(connection)
|
|
86
|
+
|
|
87
|
+
if result.skipped?
|
|
88
|
+
puts "\nNo schema changes detected"
|
|
89
|
+
puts " Current schema matches version ##{result.version_id}"
|
|
90
|
+
puts " Hash: #{result.hash}"
|
|
91
|
+
puts ' No new version stored'
|
|
92
|
+
puts " Total versions: #{result.total_count}"
|
|
93
|
+
elsif result.stored?
|
|
94
|
+
version = result.version
|
|
95
|
+
puts "\nStored schema version ##{version.id}"
|
|
88
96
|
puts " Format: #{version.format_type}"
|
|
89
97
|
puts " Mode: #{version.output_mode}"
|
|
90
|
-
puts " Files: #{version.file_count
|
|
98
|
+
puts " Files: #{version.file_count}" if version.multi_file?
|
|
91
99
|
puts " PostgreSQL: #{version.pg_version}"
|
|
92
100
|
puts " Size: #{version.formatted_size}"
|
|
93
|
-
puts "
|
|
101
|
+
puts " Hash: #{version.content_hash}"
|
|
102
|
+
puts " Total versions: #{result.total_count}"
|
|
94
103
|
else
|
|
95
|
-
puts
|
|
104
|
+
puts "\nNo schema file found to store"
|
|
96
105
|
exit 1
|
|
97
106
|
end
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
puts "\nError storing schema version: #{e.message}"
|
|
109
|
+
puts e.backtrace.first(5).join("\n") if ENV['VERBOSE']
|
|
110
|
+
exit 1
|
|
98
111
|
end
|
|
99
112
|
|
|
100
113
|
desc 'List all stored schema versions'
|
|
101
114
|
task versions: :environment do
|
|
102
115
|
require 'better_structure_sql'
|
|
103
116
|
|
|
104
|
-
|
|
117
|
+
config = BetterStructureSql.configuration
|
|
118
|
+
unless config.enable_schema_versions
|
|
105
119
|
puts 'Schema versioning is not enabled.'
|
|
106
|
-
|
|
120
|
+
puts 'Enable it in config/initializers/better_structure_sql.rb:'
|
|
121
|
+
puts ' config.enable_schema_versions = true'
|
|
122
|
+
next
|
|
107
123
|
end
|
|
108
124
|
|
|
109
125
|
versions = BetterStructureSql::SchemaVersions.all_versions
|
|
110
126
|
if versions.empty?
|
|
111
127
|
puts 'No schema versions stored yet'
|
|
112
128
|
else
|
|
113
|
-
puts "
|
|
114
|
-
puts
|
|
115
|
-
puts '-' *
|
|
129
|
+
puts "\nSchema Versions (#{versions.count} total)\n\n"
|
|
130
|
+
puts 'ID | Format | Mode | Files | PostgreSQL | Hash | Created | Size'
|
|
131
|
+
puts '-' * 100
|
|
116
132
|
versions.each do |version|
|
|
117
|
-
puts format('%-
|
|
133
|
+
puts format('%-4d | %-6s | %-11s | %-5s | %-10s | %-8s | %-19s | %s',
|
|
118
134
|
version.id,
|
|
119
135
|
version.format_type,
|
|
120
136
|
version.output_mode,
|
|
121
|
-
version.file_count ||
|
|
122
|
-
version.pg_version,
|
|
137
|
+
version.file_count || '-',
|
|
138
|
+
version.pg_version[0..9], # Truncate long version strings
|
|
139
|
+
version.content_hash[0..7], # First 8 chars of hash
|
|
123
140
|
version.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
|
124
141
|
version.formatted_size)
|
|
125
142
|
end
|