migration_history 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,116 @@
1
+ require "parser/current"
2
+
3
+ class MigrationHistory::Extractor < Parser::AST::Processor
4
+ attr_reader :current_class, :actions
5
+ TABLE_DEFINITION_ADD_COLUMN_METHODS = %i(
6
+ column primary_key timestamps
7
+ blob tinyblob mediumblob longblob
8
+ tinytext mediumtext longtext unsigned_integer unsigned_bigint
9
+ unsigned_float unsigned_decimal bigserial bit bit_varying cidr citext daterange
10
+ hstore inet interval int4range int8range jsonb ltree macaddr
11
+ money numrange oid point line lseg box path polygon circle
12
+ serial tsrange tstzrange tsvector uuid xml timestamptz enum
13
+ bigint binary boolean date datetime decimal
14
+ float integer json string text time timestamp virtual
15
+ )
16
+
17
+ def initialize
18
+ @current_class = nil
19
+ @actions = []
20
+ end
21
+
22
+ def on_class(node)
23
+ class_name, _superclass, body = *node
24
+ @current_class = class_name.children[1].to_s
25
+ process(body)
26
+ end
27
+
28
+ def on_def(node)
29
+ method_name, _args, body = *node
30
+ @current_method = method_name
31
+ if %i(up change).include?(method_name)
32
+ process(body)
33
+ end
34
+ @current_method = nil
35
+ end
36
+
37
+ def on_send(node)
38
+ return unless @current_method
39
+ receiver, method_name, *args = *node
40
+ return unless receiver.nil?
41
+
42
+ case method_name
43
+ when :create_table
44
+ table_name = args.first.children.first
45
+ options = extract_options(args[1])
46
+ @actions << { action: :create_table, details: { table_name: table_name, options: options } }
47
+ when :add_column
48
+ table_name = args.first.children.first
49
+ options = extract_options(args[1])
50
+ @actions << { action: :add_column, details: { table_name: table_name, column_name: args[1].children.first, type: args[2].children.first, options: options } }
51
+ end
52
+ end
53
+
54
+ def on_block(node)
55
+ return unless @current_method
56
+ send_node, args, body = *node
57
+ _, method_name, *_ = *send_node
58
+
59
+ table_name = send_node.children.try(:[], 2)&.children&.first
60
+ table_var_name = args.children&.first&.children&.first
61
+
62
+ return unless table_name && table_var_name
63
+
64
+ case method_name
65
+ when :create_table
66
+ @actions << { action: :create_table, details: { table_name: table_name, options: {} } }
67
+ end
68
+
69
+ handle_table_block(body, table_name, table_var_name)
70
+ end
71
+
72
+
73
+ def extract_options(option_hash_node)
74
+ return {} unless option_hash_node&.type == :hash
75
+
76
+ option_hash_node.children.each_with_object({}) do |pair, hash|
77
+ key, value = pair.children
78
+ hash[key.children.first] = value.children.first
79
+ end
80
+ end
81
+
82
+ private
83
+ def handle_table_block(body, table_name, table_var_name)
84
+ body.children.each do |child|
85
+ next if child.nil? || child.is_a?(Symbol)
86
+ next unless child.type == :send
87
+
88
+ receiver, method_name, *args = *child
89
+ next unless receiver&.type == :lvar && receiver.children.first == table_var_name
90
+ if TABLE_DEFINITION_ADD_COLUMN_METHODS.include?(method_name)
91
+ if method_name == :timestamps
92
+ %i(created_at updated_at).each do |column_name|
93
+ @actions << {
94
+ action: :add_column,
95
+ details: { table_name: table_name, column_name: column_name, type: :datetime, options: {} }
96
+ }
97
+ end
98
+ else
99
+ column_name = args[0]&.children&.first
100
+ type = not_column_type_method?(method_name) ? args[1].children.first : method_name
101
+ options = not_column_type_method?(method_name) ? extract_options(args[2]) : extract_options(args[1])
102
+
103
+ next unless column_name
104
+ @actions << {
105
+ action: :add_column,
106
+ details: { table_name: table_name, column_name: column_name, type: type, options: options }
107
+ }
108
+ end
109
+ end
110
+ end
111
+ end
112
+
113
+ def not_column_type_method?(method_name)
114
+ %i(primary_key column).include?(method_name)
115
+ end
116
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MigrationHistory
4
+ module Formatter
5
+ class Base
6
+ attr_accessor :output_file_name
7
+
8
+ DEFAULT_OUTPUT_FILE_NAME = "migration_history"
9
+
10
+ def initialize
11
+ @migration_actions = []
12
+ @output_file_name ||= DEFAULT_OUTPUT_FILE_NAME
13
+ end
14
+
15
+ def format(result_set)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ def file_extension
20
+ raise NotImplementedError
21
+ end
22
+
23
+ def output_file_name_with_extension
24
+ "#{output_file_name}.#{file_extension}"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "migration_history/formatter/base"
4
+
5
+ module MigrationHistory
6
+ module Formatter
7
+ class HtmlFormatter < Base
8
+ def format(result_set)
9
+ File.open(File.join(Dir.pwd, output_file_name_with_extension), "wb") do |file|
10
+ file.puts template("template").result(binding)
11
+ end
12
+ end
13
+
14
+ def template(name)
15
+ ERB.new(File.read(File.join(File.dirname(__FILE__), "../../../views/", "#{name}.erb")))
16
+ end
17
+
18
+ def file_extension
19
+ "html"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "migration_history/formatter/base"
4
+
5
+ module MigrationHistory
6
+ module Formatter
7
+ class JsonFormatter < Base
8
+ def format(result_set)
9
+ File.open(File.join(Dir.pwd, output_file_name_with_extension), "wb") do |file|
10
+ file.puts result_set.original_result.to_json
11
+ end
12
+ end
13
+
14
+ def file_extension
15
+ "json"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,51 @@
1
+ require "migration_history/result_set"
2
+ require "migration_history/tracker"
3
+
4
+ module MigrationHistory
5
+ module QueryMethods
6
+ @migration_file_dir = "db/migrate"
7
+ @tracker = nil
8
+
9
+ module ClassMethods
10
+ attr_accessor :migration_file_dir
11
+
12
+ def filter(table_name, column_name = nil, action_name = nil)
13
+ table_name = table_name.to_sym
14
+ column_name = column_name.to_sym if column_name
15
+ action_name = action_name.to_sym if action_name
16
+ raise InvalidError.new("Table name is required") unless table_name
17
+
18
+ found_migration_info = {}
19
+ tracker.migration_info.values.each do |v|
20
+ if v[:actions].to_a.any? { |action|
21
+ action.dig(:details, :table_name) == table_name &&
22
+ (column_name.nil? || action.dig(:details, :column_name) == column_name) &&
23
+ (action_name.nil? || action[:action] == action_name)
24
+ }
25
+
26
+ found_migration_info[v[:class_name]] = v
27
+ end
28
+ end
29
+
30
+ ResultSet.new(found_migration_info)
31
+ end
32
+
33
+ def all
34
+ ResultSet.new(tracker.migration_info)
35
+ end
36
+
37
+ def tracker
38
+ @tracker ||= Tracker.new(migration_file_dir).tap(&:setup!)
39
+ end
40
+
41
+ def reload!
42
+ @tracker = nil
43
+ tracker
44
+ end
45
+ end
46
+
47
+ def self.included(base)
48
+ base.extend(ClassMethods)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,17 @@
1
+ require "migration_history/formatter/base"
2
+ require "migration_history/formatter/html_formatter"
3
+
4
+ module MigrationHistory
5
+ class ResultSet
6
+ attr_accessor :original_result
7
+
8
+ def initialize(original_result)
9
+ @original_result = original_result
10
+ end
11
+
12
+ def format!
13
+ formatter = HTMLFormatter.new
14
+ formatter.format(self)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "migration_history/extractor"
4
+
5
+ module MigrationHistory
6
+ class Tracker
7
+ attr_accessor :migration_info, :migration_file_dir
8
+
9
+ def initialize(migration_file_dir = nil)
10
+ @migration_file_dir = migration_file_dir || "db/migrate"
11
+ @migration_info = {}
12
+ end
13
+
14
+ def setup!
15
+ migration_path = File.expand_path(File.join(Dir.pwd, @migration_file_dir))
16
+ migration_files = Dir.glob(File.join(migration_path, "*.rb"))
17
+
18
+ migration_files.each do |file|
19
+ result = extract_migration_methods(file)
20
+ @migration_info[result[:class_name]] = result
21
+ @migration_info[result[:class_name]][:timestamp] = File.basename(file).split("_").first.to_i
22
+ end
23
+ end
24
+
25
+ private
26
+ def extract_migration_methods(file_path)
27
+ code = File.read(file_path)
28
+ ast = Parser::CurrentRuby.parse(code)
29
+ return unless ast
30
+
31
+ visitor = MigrationHistory::Extractor.new
32
+ visitor.process(ast)
33
+
34
+ {
35
+ file_path: File.basename(file_path),
36
+ class_name: visitor.current_class,
37
+ actions: visitor.actions
38
+ }
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MigrationHistory
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "migration_history/cli"
4
+ require "migration_history/query"
5
+ require "active_record"
6
+ require "rails"
7
+
8
+ module MigrationHistory
9
+ include QueryMethods
10
+ class InvalidError
11
+ def initialize(message)
12
+ @message = message
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path("lib", __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require "migration_history/version"
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = "migration_history"
9
+ spec.version = MigrationHistory::VERSION
10
+ spec.authors = ["a5-stable"]
11
+ spec.email = ["sh07e1916@gmail.com"]
12
+
13
+ spec.summary = "A Gem to track and collect migration history in Rails projects, including when tables and columns were added, with details available in various formats"
14
+ spec.description = "Track and collect detailed migration history in Rails, including table/column changes, with flexible output formats such as HTML, CLI, and more."
15
+ spec.homepage = "https://github.com/a5-stable"
16
+ spec.license = "MIT"
17
+
18
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
19
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
20
+ if spec.respond_to?(:metadata)
21
+ # spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
22
+
23
+ spec.metadata["homepage_uri"] = spec.homepage
24
+ spec.metadata["source_code_uri"] = "https://github.com/a5-stable/migration_history"
25
+ spec.metadata["changelog_uri"] = "https://github.com/a5-stable/migration_history"
26
+ else
27
+ raise "RubyGems 2.0 or newer is required to protect against " \
28
+ "public gem pushes."
29
+ end
30
+
31
+ # Specify which files should be added to the gem when it is released.
32
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
33
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
34
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
35
+ end
36
+ spec.bindir = "exe"
37
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
38
+ spec.require_paths = ["lib"]
39
+
40
+ spec.add_dependency "rails", ">= 6.0"
41
+
42
+ spec.add_development_dependency "bundler"
43
+ spec.add_development_dependency "rake", ">= 12.2"
44
+ spec.add_development_dependency "rspec", "~> 3.0"
45
+ spec.add_development_dependency "rubocop"
46
+ spec.add_development_dependency "thor", "~> 1.0"
47
+ end
Binary file
@@ -0,0 +1,282 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Migration History</title>
7
+ <style>
8
+ body {
9
+ font-family: Arial, sans-serif;
10
+ margin: 0;
11
+ padding: 0;
12
+ background-color: #f4f4f4;
13
+ }
14
+ .container {
15
+ width: 80%;
16
+ margin: 0 auto;
17
+ padding: 20px;
18
+ background-color: #fff;
19
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
20
+ border-radius: 8px;
21
+ }
22
+ h1 {
23
+ text-align: center;
24
+ color: #333;
25
+ margin-bottom: 20px;
26
+ }
27
+ .filter-container {
28
+ display: flex;
29
+ flex-wrap: wrap;
30
+ gap: 20px;
31
+ padding: 15px;
32
+ background-color: #fafafa;
33
+ border: 1px solid #ddd;
34
+ border-radius: 8px;
35
+ margin-bottom: 20px;
36
+ }
37
+ .filter-group {
38
+ flex: 1;
39
+ min-width: 200px;
40
+ }
41
+ .filter-group label {
42
+ display: block;
43
+ font-weight: bold;
44
+ margin-bottom: 5px;
45
+ }
46
+ .filter-group input {
47
+ width: 100%;
48
+ padding: 10px;
49
+ border: 1px solid #ccc;
50
+ border-radius: 4px;
51
+ box-sizing: border-box;
52
+ }
53
+ .date-filter .date-inputs {
54
+ display: flex;
55
+ flex-wrap: wrap;
56
+ gap: 10px;
57
+ }
58
+ .date-filter button {
59
+ padding: 10px 15px;
60
+ background-color: #007BFF;
61
+ color: #fff;
62
+ border: none;
63
+ border-radius: 4px;
64
+ cursor: pointer;
65
+ transition: background-color 0.3s;
66
+ }
67
+ .date-filter button:hover {
68
+ background-color: #0056b3;
69
+ }
70
+ .date-filter-wrapper {
71
+ background-color: #f0f0f0; /* 任意の背景色 */
72
+ padding: 15px;
73
+ border-radius: 8px; /* 任意の角丸 */
74
+ }
75
+ .date-filter-wrapper .date-filter {
76
+ margin-bottom: 10px;
77
+ }
78
+ table {
79
+ width: 100%;
80
+ border-collapse: collapse;
81
+ margin-bottom: 20px;
82
+ table-layout: fixed;
83
+ word-break: break-all;
84
+ word-wrap: break-all;
85
+ }
86
+ th, td {
87
+ padding: 12px;
88
+ text-align: left;
89
+ border: 1px solid #ddd;
90
+ }
91
+ th {
92
+ background-color: #f4f4f4;
93
+ position: sticky;
94
+ top: 0;
95
+ z-index: 1;
96
+ }
97
+ th.sortable-header {
98
+ cursor: pointer;
99
+ user-select: none;
100
+ }
101
+ th.sortable-header:hover {
102
+ background-color: #eaeaea;
103
+ }
104
+ tr:nth-child(even) {
105
+ background-color: #f9f9f9;
106
+ }
107
+ tr:hover {
108
+ background-color: #f1f1f1;
109
+ }
110
+ .sort-arrow {
111
+ margin-left: 5px;
112
+ visibility: hidden;
113
+ }
114
+ .sorted-asc .sort-arrow::after {
115
+ content: '▲';
116
+ }
117
+ .sorted-desc .sort-arrow::after {
118
+ content: '▼';
119
+ }
120
+ </style>
121
+ </head>
122
+ <body>
123
+ <div class="container">
124
+ <h1>Migration History</h1>
125
+ <div class="filter-container">
126
+ <div class="filter-group">
127
+ <label for="searchInput">Keyword Search</label>
128
+ <input type="text" id="searchInput" placeholder="Search by any keyword...">
129
+ </div>
130
+ <div class="filter-group">
131
+ <label for="searchInputByCol1">Target Table</label>
132
+ <input type="text" id="searchInputByCol1" placeholder="Search Target Table" onkeyup="filterTable(1)">
133
+ </div>
134
+ <div class="filter-group">
135
+ <label for="searchInputByCol2">Action</label>
136
+ <input type="text" id="searchInputByCol2" placeholder="Search Action" onkeyup="filterTable(2)">
137
+ </div>
138
+ <div class="filter-group date-filter-wrapper">
139
+ <div class="filter-group date-filter">
140
+ <label>Date Range</label>
141
+ <div class="date-inputs">
142
+ <input type="date" id="searchInputBydatetimeStart" aria-label="Start Date">
143
+ <input type="date" id="searchInputBydatetimeEnd" aria-label="End Date">
144
+ <button onclick="filterByDateRange()">Filter by Date</button>
145
+ </div>
146
+ </div>
147
+ </div>
148
+ </div>
149
+ <table id="migrationTable">
150
+ <thead>
151
+ <tr id="migrationTable-header">
152
+ <th class="sortable-header" onclick="sortTable(0)">DateTime <span class="sort-arrow"></span></th>
153
+ <th class="sortable-header" onclick="sortTable(1)">Target Table <span class="sort-arrow"></span></th>
154
+ <th class="sortable-header" onclick="sortTable(2)">Action <span class="sort-arrow"></span></th>
155
+ <th>Details</th>
156
+ <th>File Name</th>
157
+ </tr>
158
+ </thead>
159
+ <tbody>
160
+ <% result_set.original_result.each_value do |migration| %>
161
+ <% migration[:actions].each do |action| %>
162
+ <tr class="migrationTable-row">
163
+ <td><%= Time.parse(migration[:timestamp].to_s).strftime('%Y-%m-%d %H:%M') %></td>
164
+ <td><%= action.dig(:details, :table_name) %></td>
165
+ <td><%= action[:action] %></td>
166
+ <td>
167
+ Table: <%= action.dig(:details, :table_name) %><br>
168
+ <% if action.dig(:details, :column_name) %>
169
+ Column: <%= action.dig(:details, :column_name) %><br>
170
+ Type: <%= action.dig(:details, :type) %><br>
171
+ <% end %>
172
+ Options: <%= action.dig(:details, :options).inspect %>
173
+ </td>
174
+ <td><%= migration[:file_path] %></td>
175
+ </tr>
176
+ <% end %>
177
+ <% end %>
178
+ </tbody>
179
+ </table>
180
+ </div>
181
+
182
+ <script>
183
+ let currentSortColumn = null; // To store the current sorted column index
184
+ let currentSortDirection = true; // true for ascending, false for descending
185
+
186
+ document.getElementById('searchInput').addEventListener('input', function() {
187
+ const filter = this.value.toLowerCase();
188
+ const rows = document.querySelectorAll('#migrationTable .migrationTable-row');
189
+
190
+ rows.forEach(row => {
191
+ const text = row.textContent.toLowerCase();
192
+ row.style.display = text.includes(filter) ? '' : 'none';
193
+ });
194
+ });
195
+
196
+ document.querySelectorAll('.searchInputByCol').forEach((input, index) => {
197
+ input.addEventListener('input', function() {
198
+ filterTable(index);
199
+ });
200
+ });
201
+
202
+ function filterTable(index) {
203
+ const filter = document.getElementById(`searchInputByCol${index}`).value.toLowerCase();
204
+ const rows = document.querySelectorAll('#migrationTable .migrationTable-row');
205
+
206
+ rows.forEach(row => {
207
+ const text = row.querySelectorAll('td')[index].textContent.toLowerCase();
208
+ row.style.display = text.includes(filter) ? '' : 'none';
209
+ });
210
+ }
211
+
212
+ function filterByDateRange() {
213
+ const startInput = document.getElementById('searchInputBydatetimeStart').value;
214
+ const endInput = document.getElementById('searchInputBydatetimeEnd').value;
215
+ const startDate = startInput ? new Date(startInput) : null;
216
+ const endDate = endInput ? new Date(endInput) : null;
217
+ const rows = document.querySelectorAll('#migrationTable .migrationTable-row');
218
+
219
+ rows.forEach(row => {
220
+ const dateCell = row.cells[0];
221
+ if (dateCell) {
222
+ const rowDate = new Date(dateCell.textContent.trim());
223
+ if (
224
+ (!startDate || rowDate >= startDate) &&
225
+ (!endDate || rowDate <= endDate)
226
+ ) {
227
+ row.style.display = '';
228
+ } else {
229
+ row.style.display = 'none';
230
+ }
231
+ }
232
+ });
233
+ }
234
+
235
+ function sortTable(columnIndex) {
236
+ const rows = Array.from(document.querySelectorAll('#migrationTable .migrationTable-row'));
237
+ const direction = currentSortColumn === columnIndex && currentSortDirection ? -1 : 1; // Toggle sort direction if same column
238
+
239
+ // Reset all column arrows
240
+ resetSortArrows();
241
+
242
+ rows.sort((rowA, rowB) => {
243
+ const cellA = rowA.cells[columnIndex].textContent.trim();
244
+ const cellB = rowB.cells[columnIndex].textContent.trim();
245
+
246
+ if (columnIndex === 0) { // For DateTime column, we compare Date objects
247
+ return (new Date(cellA) - new Date(cellB)) * direction;
248
+ }
249
+
250
+ return cellA.localeCompare(cellB) * direction;
251
+ });
252
+
253
+ rows.forEach(row => document.querySelector('tbody').appendChild(row));
254
+
255
+ // Update current sort column and direction
256
+ currentSortColumn = columnIndex;
257
+ currentSortDirection = direction === 1;
258
+
259
+ updateSortArrows();
260
+ }
261
+
262
+ function resetSortArrows() {
263
+ const headerCells = document.querySelectorAll('#migrationTable th');
264
+ headerCells.forEach(th => {
265
+ th.classList.remove('sorted-asc', 'sorted-desc');
266
+ });
267
+ }
268
+
269
+ function updateSortArrows() {
270
+ const headerCells = document.querySelectorAll('#migrationTable th');
271
+ const arrow = headerCells[currentSortColumn].querySelector('.sort-arrow');
272
+ if (!arrow) return;
273
+ if (currentSortDirection) {
274
+ headerCells[currentSortColumn].classList.add('sorted-asc');
275
+ } else {
276
+ headerCells[currentSortColumn].classList.add('sorted-desc');
277
+ }
278
+ arrow.style.visibility = 'visible';
279
+ }
280
+ </script>
281
+ </body>
282
+ </html>