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.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +3 -0
- data/.rubocop.yml +291 -0
- data/.travis.yml +7 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +87 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/exe/migration_history +6 -0
- data/lib/migration_history/cli.rb +61 -0
- data/lib/migration_history/extractor.rb +116 -0
- data/lib/migration_history/formatter/base.rb +28 -0
- data/lib/migration_history/formatter/html_formatter.rb +23 -0
- data/lib/migration_history/formatter/json_formatter.rb +19 -0
- data/lib/migration_history/query.rb +51 -0
- data/lib/migration_history/result_set.rb +17 -0
- data/lib/migration_history/tracker.rb +41 -0
- data/lib/migration_history/version.rb +5 -0
- data/lib/migration_history.rb +15 -0
- data/migration_history.gemspec +47 -0
- data/migration_history_sample.png +0 -0
- data/views/template.erb +282 -0
- metadata +158 -0
@@ -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,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
|
data/views/template.erb
ADDED
@@ -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>
|