migsupo 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.
@@ -0,0 +1,185 @@
1
+ require_relative "../differ/operations/create_table"
2
+ require_relative "../differ/operations/drop_table"
3
+ require_relative "../differ/operations/add_column"
4
+ require_relative "../differ/operations/remove_column"
5
+ require_relative "../differ/operations/change_column"
6
+ require_relative "../differ/operations/rename_column"
7
+ require_relative "../differ/operations/add_index"
8
+ require_relative "../differ/operations/remove_index"
9
+
10
+ module Migsupo
11
+ module Generator
12
+ class MigrationBuilder
13
+ def initialize(rails_version: nil)
14
+ @rails_version = rails_version
15
+ end
16
+
17
+ def build(operations, class_name:)
18
+ all_reversible = operations.all?(&:reversible?)
19
+
20
+ body = if all_reversible
21
+ indent("def change\n#{indent(render_operations(operations))}\nend", 2)
22
+ else
23
+ up = indent("def up\n#{indent(render_operations(operations, direction: :up))}\nend", 2)
24
+ down = indent("def down\n#{indent(render_operations(operations, direction: :down))}\nend", 2)
25
+ "#{up}\n\n#{down}"
26
+ end
27
+
28
+ <<~RUBY
29
+ class #{class_name} < ActiveRecord::Migration#{version_suffix}
30
+ #{body}
31
+ end
32
+ RUBY
33
+ end
34
+
35
+ private
36
+
37
+ def version_suffix
38
+ return "" unless @rails_version
39
+
40
+ "[#{@rails_version}]"
41
+ end
42
+
43
+ def render_operations(operations, direction: :change)
44
+ operations.map { |op| render_operation(op, direction: direction) }.join("\n")
45
+ end
46
+
47
+ def render_operation(op, direction:)
48
+ case op
49
+ when Differ::Operations::CreateTable
50
+ render_create_table(op)
51
+ when Differ::Operations::DropTable
52
+ render_drop_table(op, direction: direction)
53
+ when Differ::Operations::AddColumn
54
+ direction == :down ? render_remove_column_from_add(op) : render_add_column(op)
55
+ when Differ::Operations::RemoveColumn
56
+ direction == :down ? render_add_column_from_remove(op) : render_remove_column(op)
57
+ when Differ::Operations::ChangeColumn
58
+ direction == :down ? render_change_column(op, column: op.old_column) : render_change_column(op, column: op.new_column)
59
+ when Differ::Operations::RenameColumn
60
+ direction == :down ? render_rename_column(op, reverse: true) : render_rename_column(op)
61
+ when Differ::Operations::AddIndex
62
+ direction == :down ? render_remove_index(op.table_name, op.index) : render_add_index(op.table_name, op.index)
63
+ when Differ::Operations::RemoveIndex
64
+ direction == :down ? render_add_index(op.table_name, op.index) : render_remove_index(op.table_name, op.index)
65
+ else
66
+ "# Unknown operation: #{op.class}"
67
+ end
68
+ end
69
+
70
+ def render_create_table(op)
71
+ table = op.table
72
+ opts = table_options_str(table.options.reject { |k, _| k == :force })
73
+ columns = table.columns.map { |col| " #{render_column_in_table(col)}" }.join("\n")
74
+
75
+ idx_lines = table.indexes.map { |idx| render_add_index(table.name, idx) }.join("\n")
76
+
77
+ lines = ["create_table #{table.name.inspect}#{opts} do |t|"]
78
+ lines << collapse_timestamps(table.columns, columns)
79
+ lines << "end"
80
+ lines << idx_lines unless idx_lines.empty?
81
+ lines.join("\n")
82
+ end
83
+
84
+ def collapse_timestamps(columns, rendered)
85
+ col_names = columns.map(&:name)
86
+ if col_names.include?("created_at") && col_names.include?("updated_at")
87
+ created = columns.find { |c| c.name == "created_at" }
88
+ updated = columns.find { |c| c.name == "updated_at" }
89
+ if created.type == :datetime && updated.type == :datetime &&
90
+ created.comparable_options.empty? && updated.comparable_options.empty?
91
+ rendered
92
+ .gsub(/\s*t\.datetime :created_at[^\n]*\n/, "")
93
+ .gsub(/\s*t\.datetime :updated_at[^\n]*/, "")
94
+ .sub(/\n*$/, "\n\n t.timestamps")
95
+ else
96
+ rendered
97
+ end
98
+ else
99
+ rendered
100
+ end
101
+ end
102
+
103
+ def render_drop_table(op, direction:)
104
+ if direction == :down
105
+ render_create_table(Operations::CreateTable.new(op.table))
106
+ else
107
+ "drop_table #{op.table_name.inspect}"
108
+ end
109
+ end
110
+
111
+ def render_add_column(op)
112
+ col = op.column
113
+ opts = column_options_str(col.options)
114
+ "add_column #{op.table_name.inspect}, #{col.name.inspect}, :#{col.type}#{opts}"
115
+ end
116
+
117
+ def render_remove_column(op)
118
+ col = op.column
119
+ opts = column_options_str(col.options.merge(type: col.type))
120
+ "remove_column #{op.table_name.inspect}, #{col.name.inspect}#{opts}"
121
+ end
122
+
123
+ def render_remove_column_from_add(op)
124
+ "remove_column #{op.table_name.inspect}, #{op.column.name.inspect}"
125
+ end
126
+
127
+ def render_add_column_from_remove(op)
128
+ col = op.column
129
+ opts = column_options_str(col.options)
130
+ "add_column #{op.table_name.inspect}, #{col.name.inspect}, :#{col.type}#{opts}"
131
+ end
132
+
133
+ def render_change_column(op, column:)
134
+ opts = column_options_str(column.options)
135
+ "change_column #{op.table_name.inspect}, #{column.name.inspect}, :#{column.type}#{opts}"
136
+ end
137
+
138
+ def render_rename_column(op, reverse: false)
139
+ old_name = reverse ? op.new_name : op.old_name
140
+ new_name = reverse ? op.old_name : op.new_name
141
+ "rename_column #{op.table_name.inspect}, #{old_name.inspect}, #{new_name.inspect}"
142
+ end
143
+
144
+ def render_add_index(table_name, idx)
145
+ cols = idx.columns.map(&:inspect).join(", ")
146
+ opts = index_options_str(idx)
147
+ "add_index #{table_name.inspect}, [#{cols}]#{opts}"
148
+ end
149
+
150
+ def render_remove_index(table_name, idx)
151
+ "remove_index #{table_name.inspect}, name: #{idx.name.inspect}"
152
+ end
153
+
154
+ def render_column_in_table(col)
155
+ opts = column_options_str(col.options)
156
+ "t.#{col.type} #{col.name.inspect}#{opts}"
157
+ end
158
+
159
+ def column_options_str(opts)
160
+ return "" if opts.nil? || opts.empty?
161
+
162
+ pairs = opts.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
163
+ ", #{pairs}"
164
+ end
165
+
166
+ def table_options_str(opts)
167
+ return "" if opts.nil? || opts.empty?
168
+
169
+ pairs = opts.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
170
+ ", #{pairs}"
171
+ end
172
+
173
+ def index_options_str(idx)
174
+ opts = idx.comparable_options
175
+ opts[:name] = idx.name
176
+ pairs = opts.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
177
+ ", #{pairs}"
178
+ end
179
+
180
+ def indent(str, spaces = 2)
181
+ str.lines.map { |line| line.chomp.empty? ? line : (" " * (spaces / 2)) + line }.join
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,65 @@
1
+ require_relative "naming"
2
+ require_relative "migration_builder"
3
+ require_relative "../differ/operations/create_table"
4
+ require_relative "../differ/operations/drop_table"
5
+
6
+ module Migsupo
7
+ module Generator
8
+ class MigrationGenerator
9
+ def initialize(rails_version: nil)
10
+ @rails_version = rails_version
11
+ @builder = MigrationBuilder.new(rails_version: rails_version)
12
+ end
13
+
14
+ def generate(diff, output_dir:, dry_run: false)
15
+ return [] if diff.empty?
16
+
17
+ base_ts = base_timestamp
18
+ groups = group_operations(diff.operations)
19
+ files = []
20
+
21
+ groups.each_with_index do |ops, i|
22
+ class_name = Naming.class_name(ops)
23
+ timestamp = (base_ts + i).to_s
24
+ file_name = Naming.file_name(timestamp: timestamp, class_name: class_name)
25
+ content = @builder.build(ops, class_name: class_name)
26
+
27
+ if dry_run
28
+ puts "# #{file_name}"
29
+ puts content
30
+ puts
31
+ else
32
+ file_path = File.join(output_dir, file_name)
33
+ File.write(file_path, content)
34
+ files << file_path
35
+ end
36
+ end
37
+
38
+ files
39
+ end
40
+
41
+ private
42
+
43
+ def group_operations(operations)
44
+ groups = []
45
+
46
+ create_ops = operations.select { |op| op.is_a?(Differ::Operations::CreateTable) }
47
+ drop_ops = operations.select { |op| op.is_a?(Differ::Operations::DropTable) }
48
+ other_ops = operations.reject { |op| op.is_a?(Differ::Operations::CreateTable) || op.is_a?(Differ::Operations::DropTable) }
49
+
50
+ create_ops.each { |op| groups << [op] }
51
+ drop_ops.each { |op| groups << [op] }
52
+
53
+ other_ops.group_by(&:table_name).each_value do |ops|
54
+ groups << ops
55
+ end
56
+
57
+ groups
58
+ end
59
+
60
+ def base_timestamp
61
+ Time.now.strftime("%Y%m%d%H%M%S").to_i
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,45 @@
1
+ module Migsupo
2
+ module Generator
3
+ module Naming
4
+ module_function
5
+
6
+ def class_name(operations)
7
+ types = operations.map(&:migration_type).uniq
8
+ tables = operations.map(&:table_name).uniq
9
+
10
+ if tables.size > 1
11
+ "SchemaChanges"
12
+ elsif types == [:create_table]
13
+ "Create#{camelize(tables.first)}"
14
+ elsif types == [:drop_table]
15
+ "Drop#{camelize(tables.first)}"
16
+ elsif types.all? { |t| t == :add_column }
17
+ "AddColumnsTo#{camelize(tables.first)}"
18
+ elsif types.all? { |t| t == :remove_column }
19
+ "RemoveColumnsFrom#{camelize(tables.first)}"
20
+ elsif types.all? { |t| t == :add_index }
21
+ "AddIndexesTo#{camelize(tables.first)}"
22
+ elsif types.all? { |t| t == :remove_index }
23
+ "RemoveIndexesFrom#{camelize(tables.first)}"
24
+ else
25
+ "Modify#{camelize(tables.first)}"
26
+ end
27
+ end
28
+
29
+ def file_name(timestamp:, class_name:)
30
+ "#{timestamp}_#{underscore(class_name)}.rb"
31
+ end
32
+
33
+ def camelize(str)
34
+ str.to_s.split("_").map(&:capitalize).join
35
+ end
36
+
37
+ def underscore(str)
38
+ str.to_s
39
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
40
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
41
+ .downcase
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,97 @@
1
+ require_relative "../schema/column_definition"
2
+ require_relative "../schema/index_definition"
3
+ require_relative "../schema/table_definition"
4
+ require_relative "../schema/schema_definition"
5
+
6
+ module Migsupo
7
+ module Loader
8
+ class ActiveRecordLoader
9
+ def initialize(ignored_tables: [])
10
+ @ignored_tables = ignored_tables
11
+ end
12
+
13
+ def load_schema
14
+ tables = {}
15
+
16
+ connection.tables.each do |table_name|
17
+ next if @ignored_tables.include?(table_name)
18
+
19
+ columns = load_columns(table_name)
20
+ indexes = load_indexes(table_name)
21
+ options = load_table_options(table_name)
22
+
23
+ tables[table_name] = Schema::TableDefinition.new(
24
+ name: table_name,
25
+ columns: columns,
26
+ indexes: indexes,
27
+ options: options
28
+ )
29
+ end
30
+
31
+ Schema::SchemaDefinition.new(tables: tables)
32
+ end
33
+
34
+ private
35
+
36
+ def connection
37
+ ActiveRecord::Base.connection
38
+ end
39
+
40
+ def load_columns(table_name)
41
+ connection.columns(table_name).filter_map do |col|
42
+ next if col.name == "id" && primary_key_column?(table_name, col)
43
+
44
+ Schema::ColumnDefinition.new(
45
+ name: col.name,
46
+ type: col.type,
47
+ options: column_options(col)
48
+ )
49
+ end
50
+ end
51
+
52
+ def column_options(col)
53
+ opts = {}
54
+ opts[:null] = col.null unless col.null == true
55
+ opts[:default] = col.default unless col.default.nil?
56
+ opts[:limit] = col.limit if col.limit
57
+ opts[:precision] = col.precision if col.precision
58
+ opts[:scale] = col.scale if col.scale
59
+ opts[:comment] = col.comment if col.respond_to?(:comment) && col.comment
60
+ opts
61
+ end
62
+
63
+ def primary_key_column?(table_name, col)
64
+ pk = connection.primary_key(table_name)
65
+ pk == col.name
66
+ end
67
+
68
+ def load_indexes(table_name)
69
+ connection.indexes(table_name).map do |idx|
70
+ Schema::IndexDefinition.new(
71
+ table_name: table_name,
72
+ columns: idx.columns,
73
+ name: idx.name,
74
+ options: index_options(idx)
75
+ )
76
+ end
77
+ end
78
+
79
+ def index_options(idx)
80
+ opts = {}
81
+ opts[:unique] = true if idx.unique
82
+ opts[:where] = idx.where if idx.where
83
+ opts[:using] = idx.using if idx.using && idx.using.to_sym != :btree
84
+ opts
85
+ end
86
+
87
+ def load_table_options(table_name)
88
+ opts = {}
89
+ if connection.respond_to?(:table_comment)
90
+ comment = connection.table_comment(table_name)
91
+ opts[:comment] = comment if comment
92
+ end
93
+ opts
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,24 @@
1
+ require_relative "../parser/dsl_context"
2
+
3
+ module Migsupo
4
+ module Loader
5
+ # Loads the current schema state from db/schema.rb without a live DB connection.
6
+ # db/schema.rb uses the same create_table DSL as Schemafile, so we reuse DslContext.
7
+ class SchemaRbLoader
8
+ def initialize(schema_rb_path:, ignored_tables: [])
9
+ @schema_rb_path = schema_rb_path
10
+ @ignored_tables = ignored_tables
11
+ end
12
+
13
+ def load_schema
14
+ source = File.read(@schema_rb_path)
15
+ context = Parser::DslContext.new
16
+ context.instance_eval(source, @schema_rb_path, 1)
17
+ schema = context.to_schema_definition
18
+
19
+ filtered_tables = schema.tables.reject { |name, _| @ignored_tables.include?(name) }
20
+ Schema::SchemaDefinition.new(tables: filtered_tables)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,49 @@
1
+ require_relative "table_dsl_context"
2
+ require_relative "../schema/index_definition"
3
+ require_relative "../schema/schema_definition"
4
+
5
+ module Migsupo
6
+ module Parser
7
+ class DslContext
8
+ def initialize
9
+ @tables = {}
10
+ @indexes = []
11
+ end
12
+
13
+ def create_table(name, **options, &block)
14
+ ctx = TableDslContext.new(name, options)
15
+ ctx.instance_eval(&block) if block
16
+ table_def = ctx.to_table_definition
17
+
18
+ # merge standalone indexes into the table definition later via SchemaDefinition
19
+ @tables[name.to_s] = table_def
20
+ end
21
+
22
+ def add_index(table_name, columns, **options)
23
+ @indexes << Schema::IndexDefinition.new(
24
+ table_name: table_name.to_s,
25
+ columns: Array(columns).map(&:to_s),
26
+ name: options.delete(:name),
27
+ options: options
28
+ )
29
+ end
30
+
31
+ def to_schema_definition
32
+ tables = @tables.transform_values do |table_def|
33
+ standalone = @indexes.select { |i| i.table_name == table_def.name }
34
+ next table_def if standalone.empty?
35
+
36
+ merged_indexes = (table_def.indexes + standalone).uniq(&:name)
37
+ Schema::TableDefinition.new(
38
+ name: table_def.name,
39
+ columns: table_def.columns,
40
+ indexes: merged_indexes,
41
+ options: table_def.options
42
+ )
43
+ end
44
+
45
+ Schema::SchemaDefinition.new(tables: tables)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,18 @@
1
+ require_relative "dsl_context"
2
+
3
+ module Migsupo
4
+ module Parser
5
+ class SchemafileParser
6
+ def self.parse(path)
7
+ source = File.read(path)
8
+ parse_string(source, path)
9
+ end
10
+
11
+ def self.parse_string(source, filename = "(string)")
12
+ context = DslContext.new
13
+ context.instance_eval(source, filename, 1)
14
+ context.to_schema_definition
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,92 @@
1
+ require_relative "../schema/column_definition"
2
+ require_relative "../schema/index_definition"
3
+ require_relative "../schema/table_definition"
4
+
5
+ module Migsupo
6
+ module Parser
7
+ class TableDslContext
8
+ COLUMN_TYPES = %i[
9
+ string text integer bigint float decimal boolean
10
+ date datetime time timestamp binary blob json jsonb
11
+ uuid hstore inet cidr macaddr bit varbit
12
+ virtual primary_key
13
+ ].freeze
14
+
15
+ def initialize(table_name, options = {})
16
+ @table_name = table_name.to_s
17
+ @options = options
18
+ @columns = []
19
+ @indexes = []
20
+ end
21
+
22
+ COLUMN_TYPES.each do |type|
23
+ define_method(type) do |*names, **opts|
24
+ names.each { |name| add_column(name, type, opts) }
25
+ end
26
+ end
27
+
28
+ def column(name, type, **opts)
29
+ add_column(name, type, opts)
30
+ end
31
+
32
+ def references(name, **opts)
33
+ polymorphic = opts.delete(:polymorphic)
34
+ index_opt = opts.delete(:index) { true }
35
+ foreign_key = opts.delete(:foreign_key)
36
+
37
+ col_type = opts.delete(:type) { :bigint }
38
+ add_column("#{name}_id", col_type, opts)
39
+
40
+ if polymorphic
41
+ add_column("#{name}_type", :string, {})
42
+ if index_opt
43
+ add_index_entry([@table_name, "#{name}_type", "#{name}_id"],
44
+ name: "index_#{@table_name}_on_#{name}_type_and_#{name}_id")
45
+ end
46
+ elsif index_opt
47
+ idx_opts = index_opt.is_a?(Hash) ? index_opt : {}
48
+ add_index_entry(["#{name}_id"], idx_opts)
49
+ end
50
+ end
51
+
52
+ alias belongs_to references
53
+
54
+ def timestamps(**opts)
55
+ precision = opts.fetch(:precision, nil)
56
+ col_opts = precision ? { precision: precision } : {}
57
+ col_opts[:null] = opts[:null] if opts.key?(:null)
58
+ add_column("created_at", :datetime, col_opts)
59
+ add_column("updated_at", :datetime, col_opts)
60
+ end
61
+
62
+ def index(columns, **opts)
63
+ add_index_entry(Array(columns).map(&:to_s), opts)
64
+ end
65
+
66
+ def to_table_definition
67
+ Schema::TableDefinition.new(
68
+ name: @table_name,
69
+ columns: @columns,
70
+ indexes: @indexes,
71
+ options: @options
72
+ )
73
+ end
74
+
75
+ private
76
+
77
+ def add_column(name, type, opts)
78
+ @columns << Schema::ColumnDefinition.new(name: name.to_s, type: type, options: opts)
79
+ end
80
+
81
+ def add_index_entry(columns, opts)
82
+ columns = columns.map(&:to_s)
83
+ @indexes << Schema::IndexDefinition.new(
84
+ table_name: @table_name,
85
+ columns: columns,
86
+ name: opts.delete(:name),
87
+ options: opts
88
+ )
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,11 @@
1
+ require "rails/railtie"
2
+
3
+ module Migsupo
4
+ class Railtie < Rails::Railtie
5
+ railtie_name :migsupo
6
+
7
+ rake_tasks do
8
+ load File.join(__dir__, "tasks", "migsupo.rake")
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,42 @@
1
+ module Migsupo
2
+ module Schema
3
+ class ColumnDefinition
4
+ attr_reader :name, :type, :options
5
+
6
+ COMPARABLE_OPTIONS = %i[null default limit precision scale comment].freeze
7
+
8
+ def initialize(name:, type:, options: {})
9
+ @name = name.to_s
10
+ @type = type.to_sym
11
+ @options = normalize_options(options)
12
+ freeze
13
+ end
14
+
15
+ def ==(other)
16
+ return false unless other.is_a?(ColumnDefinition)
17
+
18
+ name == other.name && type == other.type && comparable_options == other.comparable_options
19
+ end
20
+
21
+ alias eql? ==
22
+
23
+ def hash
24
+ [name, type, comparable_options].hash
25
+ end
26
+
27
+ def to_h
28
+ { name: name, type: type, options: options }
29
+ end
30
+
31
+ def comparable_options
32
+ options.slice(*COMPARABLE_OPTIONS).reject { |_, v| v.nil? }
33
+ end
34
+
35
+ private
36
+
37
+ def normalize_options(opts)
38
+ opts.transform_keys(&:to_sym).freeze
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,44 @@
1
+ module Migsupo
2
+ module Schema
3
+ class IndexDefinition
4
+ attr_reader :table_name, :columns, :name, :options
5
+
6
+ def initialize(table_name:, columns:, name: nil, options: {})
7
+ @table_name = table_name.to_s
8
+ @columns = Array(columns).map(&:to_s).freeze
9
+ @options = options.transform_keys(&:to_sym).freeze
10
+ @name = (name || generate_name).to_s
11
+ freeze
12
+ end
13
+
14
+ def ==(other)
15
+ return false unless other.is_a?(IndexDefinition)
16
+
17
+ name == other.name &&
18
+ table_name == other.table_name &&
19
+ columns == other.columns &&
20
+ comparable_options == other.comparable_options
21
+ end
22
+
23
+ alias eql? ==
24
+
25
+ def hash
26
+ [table_name, name].hash
27
+ end
28
+
29
+ def to_h
30
+ { table_name: table_name, columns: columns, name: name, options: options }
31
+ end
32
+
33
+ def comparable_options
34
+ options.reject { |k, _| k == :name }
35
+ end
36
+
37
+ private
38
+
39
+ def generate_name
40
+ "index_#{table_name}_on_#{columns.join('_and_')}"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,20 @@
1
+ module Migsupo
2
+ module Schema
3
+ class SchemaDefinition
4
+ attr_reader :tables
5
+
6
+ def initialize(tables: {})
7
+ @tables = tables.freeze
8
+ freeze
9
+ end
10
+
11
+ def table(table_name)
12
+ @tables[table_name.to_s]
13
+ end
14
+
15
+ def to_h
16
+ { tables: tables.transform_values(&:to_h) }
17
+ end
18
+ end
19
+ end
20
+ end