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