rails-crud-tools 0.6.7 → 0.6.9

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,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubyXL"
4
+ require "rubyXL/convenience_methods"
5
+ require "active_record"
6
+ require "yaml"
7
+ require "erb"
8
+
9
+ module RailsCrudTools
10
+ # The CLI class provides command-line interface methods for generating CRUD files and configurations.
11
+ # It includes methods to generate CRUD files, generate configuration files, and initialize the application.
12
+ class CLI
13
+ @application_loaded = false
14
+ class << self
15
+ def generate_crud_file
16
+ load_application unless @application_loaded
17
+
18
+ # 1. `bundle exec rails routes --expanded`の結果を取得
19
+ routes_output = `bundle exec rails routes --expanded`
20
+ config = Rails::Crud::Tools::CrudConfig.instance
21
+ font_name = config.font_name
22
+
23
+ # 2. 取得した結果を区切り文字で分割
24
+ routes_lines = routes_output.split("\n").reject(&:empty?)
25
+ routes_data = []
26
+ current_route = {}
27
+
28
+ routes_lines.each do |line|
29
+ if line.start_with?("--[ Route")
30
+ routes_data << current_route unless current_route.empty?
31
+ current_route = {}
32
+ else
33
+ key, value = line.split("|").map(&:strip)
34
+ current_route[key] = value
35
+ end
36
+ end
37
+ routes_data << current_route unless current_route.empty?
38
+
39
+ # 3. 全テーブル名を取得し、アルファベット順にソート
40
+ table_names = ActiveRecord::Base.connection.tables.sort
41
+
42
+ # 4. `rubyXL`を使って`xlsx`ファイルに書き込み
43
+ workbook = RubyXL::Workbook.new
44
+ sheet = workbook[0]
45
+ sheet.sheet_name = config.sheet_name
46
+
47
+ # ヘッダー行を追加
48
+ headers = %w[Prefix Verb URI Controller#Action crud_count] + table_names
49
+
50
+ headers.each_with_index do |header, index|
51
+ cell = sheet.add_cell(0, index, header)
52
+ cell.change_fill("00FFCC")
53
+ cell.change_font_name(font_name)
54
+ cell.change_font_bold(true)
55
+ apply_borders(cell)
56
+ end
57
+
58
+ start_col = "F"
59
+ end_col = ("A".."ZZ").to_a[table_names.length + 4] # 'F'から始まる列の範囲を計算
60
+
61
+ # データ行を追加
62
+ routes_data.each_with_index do |route, row_index|
63
+ headers.each_with_index do |header, col_index|
64
+ cell = sheet.add_cell(row_index + 1, col_index, route[header])
65
+ cell.change_font_name(font_name)
66
+ apply_borders(cell)
67
+ end
68
+
69
+ # 追加: crud_count列に式を設定
70
+ crud_count_formula = "=SUMPRODUCT(LEN(#{start_col}#{row_index + 2}:#{end_col}#{row_index + 2}))"
71
+ crud_count_cell = sheet.add_cell(row_index + 1, 4, "", crud_count_formula)
72
+ crud_count_cell.change_font_name(font_name)
73
+ apply_borders(crud_count_cell)
74
+ end
75
+
76
+ # app/jobs ディレクトリ内のジョブ名を取得
77
+ job_files = Dir.glob("app/jobs/**/*.rb")
78
+ job_classes = job_files.map do |file|
79
+ File.basename(file, ".rb").camelize
80
+ end.reject { |job| job == "ApplicationJob" }.sort
81
+
82
+ # ジョブ名を Controller#Action 列に追加
83
+ job_classes.each_with_index do |job, index|
84
+ headers.each_with_index do |header, col_index|
85
+ if header == "Controller#Action"
86
+ cell = sheet.add_cell(routes_data.length + 1 + index, col_index, job)
87
+ cell.change_font_name(font_name)
88
+ else
89
+ cell = sheet.add_cell(routes_data.length + 1 + index, col_index, nil)
90
+ end
91
+ apply_borders(cell)
92
+ end
93
+
94
+ # 追加: crud_count列に式を設定
95
+ crud_count_formula = "=SUMPRODUCT(LEN(#{start_col}#{routes_data.length + 2 + index}:#{end_col}#{routes_data.length + 2 + index}))"
96
+ crud_count_cell = sheet.add_cell(routes_data.length + 1 + index, 4, "", crud_count_formula)
97
+ crud_count_cell.change_font_name(font_name)
98
+ apply_borders(crud_count_cell)
99
+ end
100
+
101
+ # ヘッダーの背景色を設定
102
+ (0..headers.length - 1).each do |col_index|
103
+ sheet[0][col_index].change_fill(config.header_bg_color)
104
+ end
105
+
106
+ # 列幅を設定
107
+ headers.each_with_index do |header, col_index|
108
+ max_length = header.length
109
+ (1..routes_data.length).each do |row_index|
110
+ cell_value = sheet[row_index][col_index].value.to_s
111
+ max_length = [max_length, cell_value.length].max
112
+ end
113
+ sheet.change_column_width(col_index, max_length + 2)
114
+ end
115
+
116
+ # ファイルを保存
117
+ crud_file = config.crud_file_path
118
+ base_dir = File.dirname(crud_file)
119
+
120
+ # base_dirが存在しなければ作成
121
+ FileUtils.mkdir_p(base_dir) unless Dir.exist?(base_dir)
122
+ workbook.write(crud_file)
123
+
124
+ puts "Output: #{crud_file}"
125
+ end
126
+
127
+ def generate_crud_config
128
+ load_application unless @application_loaded
129
+
130
+ table_names = ActiveRecord::Base.connection.tables.sort
131
+ table_start_col = table_names.any? ? table_names.first : "active_admin_comments"
132
+
133
+ config_content = <<~CONFIG
134
+ enabled: true
135
+ base_dir: doc
136
+ crud_file: crud.xlsx
137
+ sheet_name: CRUD
138
+ method_col: Verb
139
+ action_col: Controller#Action
140
+ table_start_col: #{table_start_col}
141
+ sql_logging_enabled: true
142
+ header_bg_color: 00FFCC
143
+ font_name: Arial
144
+ CONFIG
145
+
146
+ File.write(".crudconfig.yml", config_content)
147
+ puts "Generated .crudconfig.yml file"
148
+ end
149
+
150
+ def init
151
+ generate_crud_config
152
+ generate_crud_file
153
+ end
154
+
155
+ private
156
+
157
+ def load_application
158
+ return if @application_loaded
159
+
160
+ path = Dir.pwd
161
+ $stderr.puts "Loading application in '#{File.basename(path)}'..."
162
+ environment_path = "#{path}/config/environment.rb"
163
+ require environment_path
164
+
165
+ if defined? Rails
166
+ Rails.application.eager_load!
167
+ Rails.application.config.eager_load_namespaces.each(&:eager_load!) if Rails.application.config.respond_to?(:eager_load_namespaces)
168
+ end
169
+
170
+ @application_loaded = true
171
+ rescue ::LoadError
172
+ error_message = <<~EOS
173
+ Tried to load your application environment from '#{environment_path}'.
174
+ EOS
175
+ puts error_message
176
+ rescue TypeError
177
+ end
178
+
179
+ def apply_borders(cell)
180
+ %i[top bottom left right].each do |side|
181
+ cell.change_border(side, "thin")
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Crud
5
+ module Tools
6
+ module Constants
7
+ DEFAULT_METHOD = "default_method"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Rails
6
+ module Crud
7
+ module Tools
8
+ # The CrudConfig class is responsible for loading and managing the configuration settings for CRUD operations.
9
+ # It uses the Singleton pattern to ensure only one instance of the configuration exists.
10
+ class CrudConfig
11
+ include Singleton
12
+
13
+ attr_reader :enabled, :base_dir, :crud_file, :sheet_name, :method_col, :action_col, :table_start_col, :sql_logging_enabled, :header_bg_color, :font_name, :config_file
14
+
15
+ def initialize
16
+ @config_file = ".crudconfig.yml"
17
+ @last_loaded = nil
18
+ load_config
19
+ end
20
+
21
+ def load_config
22
+ return unless @last_loaded.nil? || File.mtime(@config_file) > @last_loaded
23
+ raise "Config file not found: #{@config_file}. Please generate it using `bundle exec crud gen_config`." unless File.exist?(@config_file)
24
+
25
+ config = YAML.load_file(@config_file)
26
+
27
+ @enabled = config["enabled"]
28
+ @base_dir = config["base_dir"]
29
+ @crud_file = config["crud_file"]
30
+ @sheet_name = config["sheet_name"]
31
+ @method_col = config["method_col"]
32
+ @action_col = config["action_col"]
33
+ @table_start_col = config["table_start_col"]
34
+ @sql_logging_enabled = config["sql_logging_enabled"]
35
+ @header_bg_color = config["header_bg_color"]
36
+ @font_name = config["font_name"]
37
+
38
+ @last_loaded = File.mtime(@config_file)
39
+ end
40
+
41
+ def crud_file_path
42
+ File.join(@base_dir, @crud_file)
43
+ end
44
+
45
+ def crud_log_path
46
+ File.join(@base_dir, @crud_log)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+ require_relative "crud_logger"
5
+ require_relative "constants"
6
+
7
+ module Rails
8
+ module Crud
9
+ module Tools
10
+ # The CrudData class is responsible for loading and managing CRUD data from a file.
11
+ # It includes methods to load data, reload if needed, and retrieve specific information from the data.
12
+ class CrudData
13
+ include Singleton
14
+
15
+ attr_accessor :process_id, :crud_rows, :crud_cols, :workbook, :last_loaded_time
16
+
17
+ def initialize
18
+ @process_id = nil
19
+ @crud_rows = {}
20
+ @crud_cols = {}
21
+ @last_loaded_time = nil
22
+ end
23
+
24
+ def load_crud_data
25
+ config = CrudConfig.instance
26
+ return unless config.enabled
27
+
28
+ unless File.exist?(config.crud_file_path)
29
+ CrudLogger.logger.warn "CRUD file not found: #{config.crud_file_path}"
30
+ return false
31
+ end
32
+
33
+ @workbook = RubyXL::Parser.parse(config.crud_file_path)
34
+ @last_loaded_time = File.mtime(config.crud_file_path)
35
+ sheet = crud_sheet
36
+ headers = sheet[0].cells.map(&:value)
37
+
38
+ method_col_index = headers.index(config.method_col)
39
+ action_col_index = headers.index(config.action_col)
40
+ table_start_col_index = headers.index(config.table_start_col)
41
+
42
+ raise "Method column not found" unless method_col_index
43
+ raise "Action column not found" unless action_col_index
44
+ raise "Table start column not found" unless table_start_col_index
45
+
46
+ headers[table_start_col_index..].each_with_index do |table_name, index|
47
+ @crud_cols[table_name] = table_start_col_index + index
48
+ end
49
+
50
+ sheet.each_with_index do |row, index|
51
+ next if index.zero?
52
+
53
+ method = row[method_col_index]&.value.to_s.strip
54
+ method = Constants::DEFAULT_METHOD if method.empty?
55
+ value = row[action_col_index]&.value
56
+ split_value = value&.split
57
+ action = split_value&.first
58
+ next if action.nil?
59
+
60
+ @crud_rows[method] ||= {}
61
+ @crud_rows[method][action] = index
62
+ end
63
+ end
64
+
65
+ # CRUDデータが更新された場合に再読み込みする
66
+ def reload_if_needed
67
+ config = CrudConfig.instance
68
+ return unless config.enabled
69
+
70
+ return unless @last_loaded_time.nil? || File.mtime(config.crud_file_path) > @last_loaded_time
71
+
72
+ last_modified_by = get_last_modified_by(config.crud_file_path)
73
+ CrudLogger.logger.info "last modified by: #{last_modified_by}. process_id: #{process_id}"
74
+ return if process_id == last_modified_by
75
+
76
+ CrudLogger.logger.info "Reloading CRUD data due to file modification. last_loaded_time = #{@last_loaded_time}"
77
+ load_crud_data
78
+ end
79
+
80
+ # xlsxファイルの最終更新者を取得する
81
+ def get_last_modified_by(file_path)
82
+ last_modified_by = nil
83
+
84
+ Zip::File.open(file_path) do |zipfile|
85
+ doc_props = zipfile.find_entry("docProps/core.xml")
86
+ if doc_props
87
+ content = doc_props.get_input_stream.read
88
+ last_modified_by = content[%r{<cp:lastModifiedBy>(.*?)<\\\\/cp:lastModifiedBy>}, 1]
89
+ else
90
+ CrudLogger.logger.warn "docProps/core.xml が見つかりませんでした。"
91
+ end
92
+ end
93
+
94
+ last_modified_by
95
+ end
96
+
97
+ # CRUDシートを取得する
98
+ def crud_sheet
99
+ sheet_name = CrudConfig.instance.sheet_name
100
+ sheet = @workbook[sheet_name]
101
+ raise "CRUD sheet '#{sheet_name}' not found" if sheet.nil?
102
+
103
+ sheet
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "singleton"
5
+
6
+ module Rails
7
+ module Crud
8
+ module Tools
9
+ # The CrudLogger class is responsible for logging CRUD operations to a file.
10
+ # It uses the Singleton pattern to ensure only one instance of the logger exists.
11
+ class CrudLogger
12
+ include Singleton
13
+
14
+ attr_reader :logger
15
+
16
+ def initialize
17
+ @logger = Logger.new("log/crud.log")
18
+ end
19
+
20
+ def self.logger
21
+ instance.logger
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module Rails
6
+ module Crud
7
+ # The Tools module provides utility methods for setting up notifications and processing SQL queries.
8
+ # It includes methods to subscribe to ActiveSupport notifications and handle different types of SQL operations.
9
+ module Tools
10
+ def self.setup_notifications
11
+ # 初回呼び出し時に @subscribed を false に設定
12
+ @subscribed ||= false
13
+ # 既に通知が登録されている場合は処理を中断
14
+ return if @subscribed
15
+
16
+ if CrudConfig.instance.enabled
17
+ # SQL クエリを監視する
18
+ ActiveSupport::Notifications.subscribe(/sql.active_record/) do |_name, _started, _finished, _unique_id, data|
19
+ process_sql(data)
20
+ end
21
+ end
22
+
23
+ # 通知の登録が完了した後に @subscribed を true に設定
24
+ @subscribed = true
25
+ end
26
+
27
+ OPERATION_UNKNOWN = "Unknown"
28
+
29
+ def self.process_sql(data)
30
+ return unless data[:sql] =~ /\A\s*(INSERT|UPDATE|DELETE|SELECT)/i
31
+
32
+ case data[:sql]
33
+ when /\bINSERT INTO\b.*\bSELECT\b/i
34
+ handle_insert_select(data)
35
+ when /\bUPDATE\b.*\bSET\b.*\bSELECT\b/i
36
+ handle_update_select(data)
37
+ when /\bDELETE\b.*\bEXISTS\b.*\bSELECT\b/i
38
+ handle_delete_select(data)
39
+ else
40
+ handle_general_sql(data)
41
+ end
42
+
43
+ return unless CrudConfig.instance.sql_logging_enabled
44
+
45
+ # SQL ログを出力
46
+ CrudLogger.logger.info "#{data[:name]} - #{data[:sql]}"
47
+ end
48
+
49
+ def self.handle_insert_select(data)
50
+ # INSERT INTO ... SELECT の特別な処理
51
+ insert_table = data[:sql].match(/INSERT INTO\s+`?(\w+)`?/i)[1]
52
+ select_tables = data[:sql].scan(/SELECT .* FROM\s+`?(\w+)`?(?:\s*,\s*`?(\w+)`?)*|JOIN\s+`?(\w+)`?/i).flatten.compact.uniq
53
+
54
+ key, method = determine_key_and_method
55
+ if key.nil? || method.nil?
56
+ CrudLogger.logger.warn "Request not found. #{data[:name]} - #{data[:sql]}"
57
+ return
58
+ end
59
+
60
+ CrudOperations.instance.add_operation(method, key, insert_table, "C")
61
+ select_tables.each do |select_table|
62
+ CrudOperations.instance.add_operation(method, key, select_table, "R")
63
+ end
64
+ end
65
+
66
+ def self.handle_update_select(data)
67
+ # UPDATE ... SET ... SELECT の特別な処理
68
+ update_table = data[:sql].match(/UPDATE\s+`?(\w+)`?/i)[1]
69
+ select_tables = data[:sql].scan(/SELECT .* FROM\s+`?(\w+)`?(?:\s*,\s*`?(\w+)`?)*|JOIN\s+`?(\w+)`?/i).flatten.compact.uniq
70
+
71
+ key, method = determine_key_and_method
72
+ if key.nil? || method.nil?
73
+ CrudLogger.logger.warn "Request not found. #{data[:name]} - #{data[:sql]}"
74
+ return
75
+ end
76
+
77
+ CrudOperations.instance.add_operation(method, key, update_table, "U")
78
+ select_tables.each do |select_table|
79
+ CrudOperations.instance.add_operation(method, key, select_table, "R")
80
+ end
81
+ end
82
+
83
+ def self.handle_delete_select(data)
84
+ # DELETE ... WHERE EXISTS ... SELECT の特別な処理
85
+ delete_table = data[:sql].match(/DELETE FROM\s+`?(\w+)`?/i)[1]
86
+ select_tables = data[:sql].scan(/SELECT .* FROM\s+`?(\w+)`?(?:\s*,\s*`?(\w+)`?)*|JOIN\s+`?(\w+)`?/i).flatten.compact.uniq
87
+
88
+ key, method = determine_key_and_method
89
+ if key.nil? || method.nil?
90
+ CrudLogger.logger.warn "Request not found. #{data[:name]} - #{data[:sql]}"
91
+ return
92
+ end
93
+
94
+ CrudOperations.instance.add_operation(method, key, delete_table, "D")
95
+ select_tables.each do |select_table|
96
+ CrudOperations.instance.add_operation(method, key, select_table, "R")
97
+ end
98
+ end
99
+
100
+ def self.handle_general_sql(data)
101
+ operation = if (match = data[:sql].match(/\A\s*(INSERT|UPDATE|DELETE|SELECT)/i))
102
+ case match[1].upcase
103
+ when "INSERT" then "C"
104
+ when "SELECT" then "R"
105
+ when "UPDATE" then "U"
106
+ when "DELETE" then "D"
107
+ else OPERATION_UNKNOWN
108
+ end
109
+ else
110
+ OPERATION_UNKNOWN
111
+ end
112
+
113
+ if operation == OPERATION_UNKNOWN
114
+ warn "Warning: Unknown SQL operation. #{data[:name]} - #{data[:sql]}"
115
+ return
116
+ end
117
+
118
+ table_names = data[:sql].scan(/(?:INSERT INTO|UPDATE|DELETE FROM|FROM|JOIN)\s+`?(\w+)`?(?:\s*,\s*`?(\w+)`?)*/i).flatten.compact.uniq
119
+ if table_names.empty?
120
+ # テーブル名が見つからない場合は警告を出力
121
+ CrudLogger.logger.warn "Table name not found in SQL: #{data[:sql]}"
122
+ return
123
+ end
124
+
125
+ key, method = determine_key_and_method
126
+ if key.nil? || method.nil?
127
+ CrudLogger.logger.warn "Request not found. #{data[:name]} - #{data[:sql]}"
128
+ return
129
+ end
130
+
131
+ # テーブル名を取得して CRUD 操作に追加
132
+ table_names.each do |table_name|
133
+ CrudOperations.instance.add_operation(method, key, table_name, operation)
134
+ end
135
+ end
136
+
137
+ # キーとメソッドを決定する
138
+ def self.determine_key_and_method
139
+ request = Thread.current[:crud_request]
140
+ sidekiq_job_class = Thread.current[:crud_sidekiq_job_class]
141
+
142
+ if request
143
+ method = request.request_method
144
+ controller = request.params["controller"]
145
+ action = request.params["action"]
146
+ key = "#{controller}##{action}"
147
+ elsif sidekiq_job_class
148
+ key = sidekiq_job_class
149
+ method = Constants::DEFAULT_METHOD
150
+ else
151
+ return nil
152
+ end
153
+
154
+ [key, method]
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "crud_logger"
4
+ require_relative "constants"
5
+
6
+ module Rails
7
+ module Crud
8
+ module Tools
9
+ # The CrudOperations class is responsible for managing CRUD operations for different tables.
10
+ # It stores operations in a nested hash structure and provides methods to add and log operations.
11
+ class CrudOperations
12
+ include Singleton
13
+
14
+ attr_accessor :table_operations, :logs
15
+
16
+ def initialize
17
+ @table_operations = Hash.new do |hash, method|
18
+ hash[method] = Hash.new do |h, key|
19
+ h[key] = Hash.new do |hh, table|
20
+ hh[table] = []
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ def add_operation(method, key, table_name, operation)
27
+ # @table_operations[method]が存在しない場合は初期化
28
+ @table_operations[method] ||= {}
29
+ # @table_operations[method][key]が存在しない場合は初期化
30
+ @table_operations[method][key] ||= {}
31
+ # @table_operations[method][key][table_name]が存在しない場合は初期化
32
+ @table_operations[method][key][table_name] ||= []
33
+
34
+ @table_operations[method][key][table_name] << operation unless @table_operations[method][key][table_name].include?(operation)
35
+ end
36
+
37
+ def log_operations(method, key)
38
+ CrudLogger.logger.info "\nSummary: Method: #{method}, Key: #{key}"
39
+
40
+ @table_operations[method][key].each do |table_name, operations|
41
+ CrudLogger.logger.info "#{table_name} - #{operations.join(", ")}"
42
+ end
43
+ end
44
+
45
+ def table_operations_present?(method, key)
46
+ return false if @table_operations[method].nil?
47
+ return false if @table_operations[method][key].nil?
48
+
49
+ !@table_operations[method][key].empty?
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end