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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/README.md +240 -31
  4. data/app/controllers/better_structure_sql/schema_versions_controller.rb +5 -4
  5. data/app/views/better_structure_sql/schema_versions/index.html.erb +6 -0
  6. data/app/views/better_structure_sql/schema_versions/show.html.erb +13 -1
  7. data/lib/better_structure_sql/adapters/base_adapter.rb +18 -0
  8. data/lib/better_structure_sql/adapters/mysql_adapter.rb +199 -4
  9. data/lib/better_structure_sql/adapters/postgresql_adapter.rb +321 -37
  10. data/lib/better_structure_sql/adapters/sqlite_adapter.rb +218 -59
  11. data/lib/better_structure_sql/configuration.rb +12 -10
  12. data/lib/better_structure_sql/dumper.rb +230 -102
  13. data/lib/better_structure_sql/errors.rb +24 -0
  14. data/lib/better_structure_sql/file_writer.rb +2 -1
  15. data/lib/better_structure_sql/generators/base.rb +38 -0
  16. data/lib/better_structure_sql/generators/comment_generator.rb +118 -0
  17. data/lib/better_structure_sql/generators/domain_generator.rb +2 -1
  18. data/lib/better_structure_sql/generators/index_generator.rb +3 -1
  19. data/lib/better_structure_sql/generators/table_generator.rb +45 -20
  20. data/lib/better_structure_sql/generators/type_generator.rb +5 -3
  21. data/lib/better_structure_sql/schema_loader.rb +3 -3
  22. data/lib/better_structure_sql/schema_version.rb +17 -1
  23. data/lib/better_structure_sql/schema_versions.rb +223 -20
  24. data/lib/better_structure_sql/store_result.rb +46 -0
  25. data/lib/better_structure_sql/version.rb +1 -1
  26. data/lib/better_structure_sql.rb +4 -1
  27. data/lib/generators/better_structure_sql/templates/README +1 -1
  28. data/lib/generators/better_structure_sql/templates/migration.rb.erb +2 -0
  29. data/lib/tasks/better_structure_sql.rake +35 -18
  30. metadata +4 -2
  31. 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
- column_defs = table[:columns].map { |col| column_definition(col) }
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
- table[:constraints]&.each do |constraint|
33
- column_defs << constraint_definition(constraint)
34
- end
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
- # For SQLite, add foreign keys inline
37
- if sqlite_adapter? && table[:foreign_keys]&.any?
38
- table[:foreign_keys].each do |fk|
39
- column_defs << foreign_key_definition(fk)
40
- end
41
- end
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
- lines << column_defs.map { |def_line| indent(def_line) }.join(",\n")
44
- lines << ');'
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
- lines.join("\n")
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
- private
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 IF NOT EXISTS #{type[:name]} AS ENUM (#{values});"
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 IF NOT EXISTS #{type[:name]} AS (#{attrs});"
38
+ "CREATE TYPE #{type[:name]} AS (#{attrs});"
38
39
  end
39
40
 
40
41
  def generate_domain(type)
41
- parts = ["CREATE DOMAIN IF NOT EXISTS #{type[:name]} AS #{type[:base_type]}"]
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 10_migrations)
38
+ # Load numbered directories in order (01_extensions through 20_migrations)
39
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|
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 :latest, -> { order(created_at: :desc).first }
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
- return nil unless content
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
- store(
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
- def store(content:, format_type:, pg_version:, **options)
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
- version = SchemaVersion.create!(
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 header
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 manifest and embed as SQL comment for later extraction
166
- manifest_path = base_path.join('_manifest.json')
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BetterStructureSql
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.1'
5
5
  end
@@ -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/example/better_structure_sql
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
- unless BetterStructureSql.configuration.enable_schema_versions
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
- exit 1
81
+ next
81
82
  end
82
83
 
83
- version = BetterStructureSql::SchemaVersions.store_current
84
-
85
- if version
86
- puts 'Schema version stored successfully'
87
- puts " ID: #{version.id}"
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 || 1}"
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 " Total versions: #{BetterStructureSql::SchemaVersions.count}"
101
+ puts " Hash: #{version.content_hash}"
102
+ puts " Total versions: #{result.total_count}"
94
103
  else
95
- puts 'No schema file found to store'
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
- unless BetterStructureSql.configuration.enable_schema_versions
117
+ config = BetterStructureSql.configuration
118
+ unless config.enable_schema_versions
105
119
  puts 'Schema versioning is not enabled.'
106
- exit 1
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 "Total versions: #{versions.count}"
114
- puts "\nID Format Mode Files PostgreSQL Created Size"
115
- puts '-' * 95
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('%-6d %-7s %-13s %-7s %-15s %-20s %s',
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 || 1,
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