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,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ # Handles writing schema output to either a single file or multiple files
5
+ # with intelligent chunking based on line count limits.
6
+ class FileWriter
7
+ attr_reader :config
8
+
9
+ def initialize(config = BetterStructureSql.configuration)
10
+ @config = config
11
+ end
12
+
13
+ # Detect if the output path is for single-file or multi-file mode
14
+ # @param path [String] The output path
15
+ # @return [Symbol] :single_file or :multi_file
16
+ def detect_output_mode(path)
17
+ # If path has no extension or is a directory, it's multi-file
18
+ # Otherwise, if it ends with .sql or .rb, it's single-file
19
+ return :multi_file if File.extname(path).empty?
20
+ return :multi_file if path.end_with?('/')
21
+
22
+ :single_file
23
+ end
24
+
25
+ # Write complete schema to a single file
26
+ # @param path [String] The file path
27
+ # @param content [String] The complete SQL content
28
+ def write_single_file(path, content)
29
+ full_path = Rails.root.join(path)
30
+ FileUtils.mkdir_p(File.dirname(full_path))
31
+ File.write(full_path, content)
32
+ end
33
+
34
+ # Write schema sections to multiple files with chunking
35
+ # @param base_path [String] The base directory path
36
+ # @param sections [Hash] Hash of section_name => array of SQL strings
37
+ # @param header [String] The header content (SET statements, etc.)
38
+ def write_multi_file(base_path, sections, header)
39
+ full_base_path = Rails.root.join(base_path)
40
+ FileUtils.mkdir_p(full_base_path)
41
+
42
+ # Write header file
43
+ write_chunk(full_base_path, '_header.sql', header)
44
+
45
+ # Define section order with directory prefixes
46
+ # Order CRITICAL for schema loading - respects dependency chain:
47
+ # extensions -> types/domains -> functions -> sequences -> tables -> indexes/fks -> views -> triggers -> migrations
48
+ section_order = {
49
+ extensions: '01_extensions', # Must be first (enable features)
50
+ types: '02_types', # Before domains and tables
51
+ domains: '02_types', # Bundled with types (domains are custom types)
52
+ functions: '03_functions', # Before triggers that call them
53
+ sequences: '04_sequences', # Before tables that use them
54
+ tables: '05_tables', # Core schema
55
+ indexes: '06_indexes', # After tables exist
56
+ foreign_keys: '07_foreign_keys', # After all tables exist
57
+ views: '08_views', # After tables (may use functions)
58
+ materialized_views: '08_views', # Bundled with views
59
+ triggers: '09_triggers', # After tables and functions
60
+ migrations: '10_migrations' # Last (schema_migrations INSERT)
61
+ }
62
+
63
+ file_map = {}
64
+ directory_file_counters = Hash.new(0) # Track file numbers per directory
65
+
66
+ # Group sections by directory to handle shared directories correctly
67
+ sections_by_directory = {}
68
+ section_order.each do |section_key, directory_name|
69
+ next unless sections.key?(section_key)
70
+
71
+ section_content = sections[section_key]
72
+ next if section_content.blank?
73
+
74
+ sections_by_directory[directory_name] ||= []
75
+ sections_by_directory[directory_name].concat(section_content)
76
+ end
77
+
78
+ # Process each directory (with merged content from multiple sections)
79
+ sections_by_directory.each do |directory_name, merged_content|
80
+ # Create directory
81
+ section_dir = full_base_path.join(directory_name)
82
+ FileUtils.mkdir_p(section_dir)
83
+
84
+ # Chunk the merged content and write files
85
+ chunks = chunk_section(merged_content, config.max_lines_per_file)
86
+ chunks.each do |chunk|
87
+ # Increment counter for this directory
88
+ directory_file_counters[directory_name] += 1
89
+ filename = format_filename(directory_file_counters[directory_name])
90
+ file_path = section_dir.join(filename)
91
+ content = chunk.join("\n\n")
92
+ File.write(file_path, "#{content}\n")
93
+
94
+ # Track in file map for manifest
95
+ relative_path = "#{directory_name}/#{filename}"
96
+ file_map[relative_path] = content
97
+ end
98
+ end
99
+
100
+ file_map
101
+ end
102
+
103
+ private
104
+
105
+ # Chunk an array of SQL strings into file-sized groups
106
+ # Each chunk respects max_lines_per_file with overflow_threshold
107
+ # Single objects larger than max_lines get their own file
108
+ #
109
+ # @param objects [Array<String>] Array of SQL strings
110
+ # @param max_lines [Integer] Maximum lines per file
111
+ # @return [Array<Array<String>>] Array of chunks, each chunk is array of SQL strings
112
+ def chunk_section(objects, max_lines)
113
+ return [] if objects.empty?
114
+
115
+ chunks = []
116
+ current_chunk = []
117
+ current_lines = 0
118
+ max_with_overflow = (max_lines * config.overflow_threshold).to_i
119
+
120
+ objects.each do |object|
121
+ object_lines = object.lines.count
122
+
123
+ # If this single object exceeds max_lines, give it a dedicated file
124
+ if object_lines > max_lines
125
+ # Flush current chunk if it has content
126
+ chunks << current_chunk unless current_chunk.empty?
127
+
128
+ # Single object in its own chunk
129
+ chunks << [object]
130
+
131
+ # Reset for next chunk
132
+ current_chunk = []
133
+ current_lines = 0
134
+ next
135
+ end
136
+
137
+ # Decide whether to start a new chunk
138
+ # If current chunk is already at max_lines, definitely start new chunk
139
+ # If current chunk is under max_lines but adding this would exceed overflow, start new chunk
140
+ # Otherwise, add to current chunk (even if it puts us slightly over max_lines, up to overflow threshold)
141
+ if current_lines >= max_lines
142
+ # Current chunk is full, start new one
143
+ chunks << current_chunk unless current_chunk.empty?
144
+ current_chunk = [object]
145
+ current_lines = object_lines
146
+ elsif current_lines.positive? && (current_lines + object_lines) > max_with_overflow
147
+ # Adding this would exceed overflow threshold, start new chunk
148
+ chunks << current_chunk
149
+ current_chunk = [object]
150
+ current_lines = object_lines
151
+ else
152
+ # Add to current chunk (may go slightly over max_lines, within overflow)
153
+ current_chunk << object
154
+ current_lines += object_lines
155
+ end
156
+ end
157
+
158
+ # Don't forget the last chunk
159
+ chunks << current_chunk unless current_chunk.empty?
160
+
161
+ chunks
162
+ end
163
+
164
+ # Write a single chunk file
165
+ # @param directory [Pathname] The directory path
166
+ # @param filename [String] The filename
167
+ # @param content [String] The file content
168
+ def write_chunk(directory, filename, content)
169
+ file_path = directory.join(filename)
170
+ File.write(file_path, content)
171
+ end
172
+
173
+ # Generate zero-padded filename
174
+ # @param index [Integer] The file number (1-based)
175
+ # @return [String] Formatted filename like "000001.sql"
176
+ def format_filename(index)
177
+ "#{index.to_s.rjust(6, '0')}.sql"
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ # Formats SQL output for consistency and readability
5
+ #
6
+ # Handles whitespace normalization, section spacing, and
7
+ # blank line management to produce clean, deterministic output.
8
+ class Formatter
9
+ attr_reader :config
10
+
11
+ def initialize(config = BetterStructureSql.configuration)
12
+ @config = config
13
+ end
14
+
15
+ # Formats SQL content with consistent spacing
16
+ #
17
+ # @param content [String] Raw SQL content
18
+ # @return [String] Formatted SQL
19
+ def format(content)
20
+ sections = parse_sections(content)
21
+ formatted = sections.map { |section| format_section(section) }
22
+
23
+ if config.add_section_spacing
24
+ formatted.join("\n\n")
25
+ else
26
+ formatted.join("\n")
27
+ end
28
+ end
29
+
30
+ # Formats an individual SQL section
31
+ #
32
+ # @param section [String] SQL section content
33
+ # @return [String] Formatted section
34
+ def format_section(section)
35
+ # Normalize whitespace
36
+ lines = section.split("\n").map(&:rstrip)
37
+
38
+ # Remove excessive blank lines
39
+ lines = collapse_blank_lines(lines)
40
+
41
+ lines.join("\n")
42
+ end
43
+
44
+ private
45
+
46
+ def parse_sections(content)
47
+ # Split content into logical sections based on SQL comments and statement types
48
+ content.split(/(?=--\s+\w+\n)/).reject(&:empty?)
49
+ end
50
+
51
+ def collapse_blank_lines(lines)
52
+ result = []
53
+ previous_blank = false
54
+
55
+ lines.each do |line|
56
+ current_blank = line.strip.empty?
57
+
58
+ if current_blank
59
+ result << line unless previous_blank
60
+ else
61
+ result << line
62
+ end
63
+
64
+ previous_blank = current_blank
65
+ end
66
+
67
+ result
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ module Generators
5
+ # Base class for all SQL generators
6
+ #
7
+ # Provides common functionality for generating SQL statements
8
+ # from database object metadata.
9
+ class Base
10
+ attr_reader :config
11
+
12
+ def initialize(config = BetterStructureSql.configuration)
13
+ @config = config
14
+ end
15
+
16
+ # Generates SQL for a database object
17
+ #
18
+ # @param object [Hash] Object metadata from introspection
19
+ # @return [String] SQL statement
20
+ # @raise [NotImplementedError] Must be implemented by subclasses
21
+ def generate(object)
22
+ raise NotImplementedError, 'Subclasses must implement #generate'
23
+ end
24
+
25
+ private
26
+
27
+ def indent(text, level = 1)
28
+ spaces = ' ' * (config.indent_size * level)
29
+ text.split("\n").map { |line| "#{spaces}#{line}" }.join("\n")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ module Generators
5
+ # Generates CREATE DOMAIN statements for custom types with constraints
6
+ class DomainGenerator < Base
7
+ # Generates CREATE DOMAIN statement
8
+ #
9
+ # @param domain [Hash] Domain metadata
10
+ # @return [String] SQL statement
11
+ def generate(domain)
12
+ schema_prefix = domain[:schema] == 'public' ? '' : "#{domain[:schema]}."
13
+
14
+ parts = ["CREATE DOMAIN IF NOT EXISTS #{schema_prefix}#{domain[:name]} AS #{domain[:base_type]}"]
15
+
16
+ parts << domain[:constraint] if domain[:constraint].present?
17
+
18
+ "#{parts.join(' ')};"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ module Generators
5
+ # Generates CREATE EXTENSION statements for PostgreSQL
6
+ class ExtensionGenerator < Base
7
+ # Generates CREATE EXTENSION or PRAGMA statement
8
+ #
9
+ # @param extension [Hash] Extension metadata
10
+ # @return [String] SQL statement
11
+ def generate(extension)
12
+ # Handle SQLite PRAGMAs (which are stored as "extensions")
13
+ return extension[:sql] if extension[:sql]
14
+
15
+ # PostgreSQL extensions
16
+ # Quote extension name if it contains special characters
17
+ ext_name = extension[:name].include?('-') ? "\"#{extension[:name]}\"" : extension[:name]
18
+ schema_clause = extension[:schema] == 'public' ? '' : " WITH SCHEMA #{extension[:schema]}"
19
+ "CREATE EXTENSION IF NOT EXISTS #{ext_name}#{schema_clause};"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ module Generators
5
+ # Generates ALTER TABLE ADD CONSTRAINT for foreign keys
6
+ #
7
+ # Handles CASCADE, RESTRICT, SET NULL actions.
8
+ class ForeignKeyGenerator < Base
9
+ # Generates ALTER TABLE ADD CONSTRAINT for foreign key
10
+ #
11
+ # @param foreign_key [Hash] Foreign key metadata
12
+ # @return [String] SQL statement
13
+ def generate(foreign_key)
14
+ parts = [
15
+ "ALTER TABLE #{foreign_key[:table]}",
16
+ "ADD CONSTRAINT #{foreign_key[:name]}",
17
+ "FOREIGN KEY (#{foreign_key[:column]})",
18
+ "REFERENCES #{foreign_key[:foreign_table]} (#{foreign_key[:foreign_column]})"
19
+ ]
20
+
21
+ on_delete = format_action(foreign_key[:on_delete])
22
+ parts << "ON DELETE #{on_delete}" if on_delete != 'NO ACTION'
23
+
24
+ on_update = format_action(foreign_key[:on_update])
25
+ parts << "ON UPDATE #{on_update}" if on_update != 'NO ACTION'
26
+
27
+ "#{parts.join(' ')};"
28
+ end
29
+
30
+ private
31
+
32
+ def format_action(action)
33
+ case action&.upcase
34
+ when 'CASCADE' then 'CASCADE'
35
+ when 'SET NULL' then 'SET NULL'
36
+ when 'SET DEFAULT' then 'SET DEFAULT'
37
+ when 'RESTRICT' then 'RESTRICT'
38
+ else 'NO ACTION'
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ module Generators
5
+ # Generates CREATE FUNCTION statements
6
+ #
7
+ # Supports PL/pgSQL, SQL, and other procedural languages.
8
+ class FunctionGenerator < Base
9
+ # Generates CREATE FUNCTION or CREATE PROCEDURE statement
10
+ #
11
+ # @param function [Hash] Function metadata with definition
12
+ # @return [String] SQL statement
13
+ def generate(function)
14
+ # PostgreSQL's pg_get_functiondef and MySQL's SHOW CREATE both return
15
+ # complete CREATE FUNCTION/PROCEDURE statements
16
+ definition = function[:definition].strip
17
+
18
+ # For MySQL, strip DEFINER clause which causes permission issues
19
+ if definition.include?('CREATE DEFINER')
20
+ # Remove DEFINER clause: "CREATE DEFINER=`user`@`host` PROCEDURE" -> "CREATE PROCEDURE"
21
+ definition = definition.gsub(/CREATE DEFINER=`[^`]+`@`[^`]+`/, 'CREATE')
22
+ end
23
+
24
+ # For dumping to structure.sql, we need to ensure the statement ends with semicolon
25
+ # MySQL SHOW CREATE PROCEDURE returns WITHOUT semicolon, so add it
26
+ # PostgreSQL pg_get_functiondef includes it, so don't add duplicate
27
+ definition += ';' unless definition.end_with?(';')
28
+
29
+ definition
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ module Generators
5
+ # Generates CREATE INDEX statements
6
+ #
7
+ # Supports unique indexes, partial indexes, and expression indexes.
8
+ class IndexGenerator < Base
9
+ # Generates CREATE INDEX statement
10
+ #
11
+ # @param index [Hash] Index metadata
12
+ # @return [String] SQL statement
13
+ def generate(index)
14
+ # PostgreSQL provides complete definition via pg_indexes
15
+ if index[:definition]
16
+ definition = index[:definition]
17
+ return definition.end_with?(';') ? definition : "#{definition};"
18
+ end
19
+
20
+ # For MySQL/SQLite, generate CREATE INDEX statement from components
21
+ unique_clause = index[:unique] ? 'UNIQUE ' : ''
22
+ columns_list = Array(index[:columns]).map { |col| quote_identifier(col) }.join(', ')
23
+ table = quote_identifier(index[:table])
24
+ name = quote_identifier(index[:name])
25
+
26
+ "CREATE #{unique_clause}INDEX IF NOT EXISTS #{name} ON #{table} (#{columns_list});"
27
+ end
28
+
29
+ private
30
+
31
+ def quote_identifier(identifier)
32
+ return identifier if identifier.nil?
33
+
34
+ # Detect adapter from ActiveRecord connection
35
+ adapter_name = begin
36
+ ActiveRecord::Base.connection.adapter_name.downcase
37
+ rescue StandardError
38
+ nil
39
+ end
40
+
41
+ # MySQL/MariaDB use backticks, PostgreSQL/SQLite use double quotes
42
+ if %w[mysql mysql2 trilogy].include?(adapter_name)
43
+ "`#{identifier}`"
44
+ else
45
+ "\"#{identifier}\""
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ module Generators
5
+ # Generates CREATE MATERIALIZED VIEW statements for PostgreSQL
6
+ class MaterializedViewGenerator < Base
7
+ # Generates CREATE MATERIALIZED VIEW statement
8
+ #
9
+ # @param matview [Hash] Materialized view metadata
10
+ # @return [String] SQL statement
11
+ def generate(matview)
12
+ schema_prefix = matview[:schema] == 'public' ? '' : "#{matview[:schema]}."
13
+ definition = matview[:definition].strip
14
+
15
+ # Ensure definition ends with semicolon
16
+ definition += ';' unless definition.end_with?(';')
17
+
18
+ output = ["CREATE MATERIALIZED VIEW IF NOT EXISTS #{schema_prefix}#{matview[:name]} AS"]
19
+ output << definition
20
+
21
+ # Add indexes if present
22
+ if matview[:indexes]&.any?
23
+ output << ''
24
+ output += matview[:indexes].map { |idx| "#{idx};" }
25
+ end
26
+
27
+ output.join("\n")
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ module Generators
5
+ # Generator for SQLite PRAGMA statements
6
+ # PRAGMAs returned by SqliteAdapter are in format:
7
+ # { name: 'foreign_keys', value: 1, sql: 'PRAGMA foreign_keys = 1;' }
8
+ # Generates PRAGMA statements for SQLite
9
+ class PragmaGenerator < Base
10
+ # Generates PRAGMA statement for SQLite
11
+ #
12
+ # @param pragma [Hash] PRAGMA metadata
13
+ # @return [String] SQL statement
14
+ def generate(pragma)
15
+ # If SQL is pre-generated, use it
16
+ return pragma[:sql] if pragma[:sql]
17
+
18
+ # Otherwise generate from name and value
19
+ "PRAGMA #{pragma[:name]} = #{pragma[:value]};"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ module Generators
5
+ # Generates CREATE SEQUENCE statements
6
+ class SequenceGenerator < Base
7
+ # Generates CREATE SEQUENCE statement
8
+ #
9
+ # @param sequence [Hash] Sequence metadata
10
+ # @return [String] SQL statement
11
+ def generate(sequence)
12
+ parts = ["CREATE SEQUENCE IF NOT EXISTS #{sequence[:name]}"]
13
+
14
+ parts << "START WITH #{sequence[:start_value]}" if sequence[:start_value]
15
+ parts << "INCREMENT BY #{sequence[:increment]}" if sequence[:increment] && sequence[:increment] != 1
16
+ parts << "MINVALUE #{sequence[:min_value]}" if sequence[:min_value]
17
+ parts << "MAXVALUE #{sequence[:max_value]}" if sequence[:max_value]
18
+
19
+ parts << "CACHE #{sequence[:cache_size]}" if sequence[:cache_size] && sequence[:cache_size] > 1
20
+
21
+ parts << 'CYCLE' if sequence[:cycle]
22
+
23
+ "#{parts.join("\n ")};"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterStructureSql
4
+ module Generators
5
+ # Generates CREATE TABLE statements with columns and constraints
6
+ #
7
+ # Handles column definitions, primary keys, constraints, and
8
+ # inline foreign keys for SQLite.
9
+ class TableGenerator < Base
10
+ attr_reader :adapter
11
+
12
+ def initialize(config, adapter = nil)
13
+ super(config)
14
+ @adapter = adapter
15
+ end
16
+
17
+ # Generates CREATE TABLE statement
18
+ #
19
+ # @param table [Hash] Table metadata with columns and constraints
20
+ # @return [String] SQL statement
21
+ def generate(table)
22
+ lines = ["CREATE TABLE IF NOT EXISTS #{table[:name]} ("]
23
+
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
31
+
32
+ table[:constraints]&.each do |constraint|
33
+ column_defs << constraint_definition(constraint)
34
+ end
35
+
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
42
+
43
+ lines << column_defs.map { |def_line| indent(def_line) }.join(",\n")
44
+ lines << ');'
45
+
46
+ lines.join("\n")
47
+ end
48
+
49
+ private
50
+
51
+ def column_definition(column)
52
+ # Quote column name for MySQL compatibility (reserved words like 'key')
53
+ column_name = quote_column_name(column[:name])
54
+ parts = [column_name, column[:type]]
55
+
56
+ parts << 'NOT NULL' unless column[:nullable]
57
+
58
+ if column[:default]
59
+ default_value = format_default(column[:default])
60
+ parts << "DEFAULT #{default_value}"
61
+ end
62
+
63
+ parts.join(' ')
64
+ end
65
+
66
+ def constraint_definition(constraint)
67
+ case constraint[:type]
68
+ when :check
69
+ when :unique
70
+ end
71
+ "CONSTRAINT #{constraint[:name]} #{constraint[:definition]}"
72
+ end
73
+
74
+ def format_default(default_value)
75
+ return default_value if default_value.nil?
76
+
77
+ # Handle nextval for sequences
78
+ return default_value if default_value.start_with?('nextval(')
79
+
80
+ # Handle NULL
81
+ return 'NULL' if default_value.upcase == 'NULL'
82
+
83
+ # Handle boolean values
84
+ return default_value if %w[true false].include?(default_value.downcase)
85
+
86
+ # Handle numeric values
87
+ return default_value if default_value.match?(/\A-?\d+(\.\d+)?\z/)
88
+
89
+ # Handle functions and expressions
90
+ return default_value if default_value.include?('(') || default_value.upcase.start_with?('CURRENT_')
91
+
92
+ # Otherwise, assume it's a string and quote it if not already quoted
93
+ default_value.start_with?("'") ? default_value : "'#{default_value}'"
94
+ end
95
+
96
+ def sqlite_adapter?
97
+ adapter&.class&.name == 'BetterStructureSql::Adapters::SqliteAdapter'
98
+ end
99
+
100
+ def foreign_key_definition(foreign_key)
101
+ parts = ["FOREIGN KEY (#{foreign_key[:column]})"]
102
+ parts << "REFERENCES #{foreign_key[:foreign_table]} (#{foreign_key[:foreign_column]})"
103
+ parts << "ON DELETE #{foreign_key[:on_delete]}" if foreign_key[:on_delete] && foreign_key[:on_delete] != 'NO ACTION'
104
+ parts << "ON UPDATE #{foreign_key[:on_update]}" if foreign_key[:on_update] && foreign_key[:on_update] != 'NO ACTION'
105
+
106
+ parts.join(' ')
107
+ end
108
+
109
+ def quote_column_name(column_name)
110
+ # Detect adapter from ActiveRecord connection
111
+ adapter_name = begin
112
+ ActiveRecord::Base.connection.adapter_name.downcase
113
+ rescue StandardError
114
+ nil
115
+ end
116
+
117
+ # MySQL/MariaDB use backticks, PostgreSQL/SQLite use double quotes
118
+ if %w[mysql mysql2 trilogy].include?(adapter_name)
119
+ "`#{column_name}`"
120
+ else
121
+ "\"#{column_name}\""
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end