rails-crud-tools 0.6.18 → 0.6.19

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,188 @@
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
+
15
+ class << self
16
+ def generate_crud_file
17
+ load_application unless @application_loaded
18
+
19
+ # 1. `bundle exec rails routes --expanded`の結果を取得
20
+ routes_output = `bundle exec rails routes --expanded`
21
+ config = Rails::Crud::Tools::CrudConfig.instance.config
22
+ font_name = config.crud_file.font_name
23
+
24
+ # 2. 取得した結果を区切り文字で分割
25
+ routes_lines = routes_output.split("\n").reject(&:empty?)
26
+ routes_data = []
27
+ current_route = {}
28
+
29
+ routes_lines.each do |line|
30
+ if line.start_with?("--[ Route")
31
+ routes_data << current_route unless current_route.empty?
32
+ current_route = {}
33
+ else
34
+ key, value = line.split("|").map(&:strip)
35
+ current_route[key] = value
36
+ end
37
+ end
38
+ routes_data << current_route unless current_route.empty?
39
+
40
+ # 3. 全テーブル名を取得し、アルファベット順にソート
41
+ table_names = ActiveRecord::Base.connection.tables.sort
42
+
43
+ # 4. `rubyXL`を使って`xlsx`ファイルに書き込み
44
+ workbook = RubyXL::Workbook.new
45
+ sheet = workbook[0]
46
+ sheet.sheet_name = config.crud_file.sheet_name
47
+
48
+ # ヘッダー行を追加
49
+ headers = %w[Prefix Verb URI Controller#Action crud_count] + table_names
50
+
51
+ headers.each_with_index do |header, index|
52
+ cell = sheet.add_cell(0, index, header)
53
+ cell.change_fill("00FFCC")
54
+ cell.change_font_name(font_name)
55
+ cell.change_font_bold(true)
56
+ apply_borders(cell)
57
+ end
58
+
59
+ start_col = "F"
60
+ end_col = ("A".."ZZ").to_a[table_names.length + 4] # 'F'から始まる列の範囲を計算
61
+
62
+ # データ行を追加
63
+ routes_data.each_with_index do |route, row_index|
64
+ headers.each_with_index do |header, col_index|
65
+ cell = sheet.add_cell(row_index + 1, col_index, route[header])
66
+ cell.change_font_name(font_name)
67
+ apply_borders(cell)
68
+ end
69
+
70
+ # 追加: crud_count列に式を設定
71
+ crud_count_formula = "=SUMPRODUCT(LEN(#{start_col}#{row_index + 2}:#{end_col}#{row_index + 2}))"
72
+ crud_count_cell = sheet.add_cell(row_index + 1, 4, "", crud_count_formula)
73
+ crud_count_cell.change_font_name(font_name)
74
+ apply_borders(crud_count_cell)
75
+ end
76
+
77
+ # app/jobs ディレクトリ内のジョブ名を取得
78
+ job_files = Dir.glob("app/jobs/**/*.rb")
79
+ job_classes = job_files.map do |file|
80
+ File.basename(file, ".rb").camelize
81
+ end.reject { |job| job == "ApplicationJob" }.sort
82
+
83
+ # ジョブ名を Controller#Action 列に追加
84
+ job_classes.each_with_index do |job, index|
85
+ headers.each_with_index do |header, col_index|
86
+ if header == "Controller#Action"
87
+ cell = sheet.add_cell(routes_data.length + 1 + index, col_index, job)
88
+ cell.change_font_name(font_name)
89
+ else
90
+ cell = sheet.add_cell(routes_data.length + 1 + index, col_index, nil)
91
+ end
92
+ apply_borders(cell)
93
+ end
94
+
95
+ # 追加: crud_count列に式を設定
96
+ crud_count_formula = "=SUMPRODUCT(LEN(#{start_col}#{routes_data.length + 2 + index}:#{end_col}#{routes_data.length + 2 + index}))"
97
+ crud_count_cell = sheet.add_cell(routes_data.length + 1 + index, 4, "", crud_count_formula)
98
+ crud_count_cell.change_font_name(font_name)
99
+ apply_borders(crud_count_cell)
100
+ end
101
+
102
+ # ヘッダーの背景色を設定
103
+ (0..headers.length - 1).each do |col_index|
104
+ sheet[0][col_index].change_fill(config.crud_file.header_bg_color)
105
+ end
106
+
107
+ # 列幅を設定
108
+ headers.each_with_index do |header, col_index|
109
+ max_length = header.length
110
+ (1..routes_data.length).each do |row_index|
111
+ cell_value = sheet[row_index][col_index].value.to_s
112
+ max_length = [max_length, cell_value.length].max
113
+ end
114
+ sheet.change_column_width(col_index, max_length + 2)
115
+ end
116
+
117
+ # ファイルを保存
118
+ crud_file = config.crud_file_path
119
+ base_dir = File.dirname(crud_file)
120
+
121
+ # base_dirが存在しなければ作成
122
+ FileUtils.mkdir_p(base_dir) unless Dir.exist?(base_dir)
123
+ workbook.write(crud_file)
124
+
125
+ puts "Output: #{crud_file}"
126
+ end
127
+
128
+ def generate_crud_config
129
+ load_application unless @application_loaded
130
+
131
+ table_names = ActiveRecord::Base.connection.tables.sort
132
+ table_start_col = table_names.any? ? table_names.first : "active_admin_comments"
133
+
134
+ config_content = <<~CONFIG
135
+ enabled: true
136
+ base_dir: doc
137
+ crud_file:
138
+ file_name: crud.xlsx
139
+ sheet_name: CRUD
140
+ header_bg_color: 00FFCC
141
+ font_name: Arial
142
+ method_col: Verb
143
+ action_col: Controller#Action
144
+ table_start_col: #{table_start_col}
145
+ sql_logging_enabled: true
146
+ CONFIG
147
+
148
+ File.write(".crudconfig.yml", config_content)
149
+ puts "Generated .crudconfig.yml file"
150
+ end
151
+
152
+ def init
153
+ generate_crud_config
154
+ generate_crud_file
155
+ end
156
+
157
+ private
158
+
159
+ def load_application
160
+ return if @application_loaded
161
+
162
+ path = Dir.pwd
163
+ $stderr.puts "Loading application in '#{File.basename(path)}'..."
164
+ environment_path = "#{path}/config/environment.rb"
165
+ require environment_path
166
+
167
+ if defined? Rails
168
+ Rails.application.eager_load!
169
+ Rails.application.config.eager_load_namespaces.each(&:eager_load!) if Rails.application.config.respond_to?(:eager_load_namespaces)
170
+ end
171
+
172
+ @application_loaded = true
173
+ rescue ::LoadError
174
+ error_message = <<~EOS
175
+ Tried to load your application environment from '#{environment_path}'.
176
+ EOS
177
+ puts error_message
178
+ rescue TypeError
179
+ end
180
+
181
+ def apply_borders(cell)
182
+ %i[top bottom left right].each do |side|
183
+ cell.change_border(side, "thin")
184
+ end
185
+ end
186
+ end
187
+ end
188
+ 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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "singleton"
5
+
6
+ module Rails
7
+ module Crud
8
+ module Tools
9
+ # The CrudConfig class is a singleton class responsible for loading and managing configuration settings from a YAML file (.crudconfig.yml).
10
+ # It ensures that the configuration is reloaded if the file is updated.
11
+ class CrudConfig
12
+ include Singleton
13
+
14
+ CONFIG_PATH = File.expand_path(".crudconfig.yml", Dir.pwd)
15
+
16
+ def initialize
17
+ @last_loaded_time = Time.at(0)
18
+ end
19
+
20
+ def self.config_path
21
+ CONFIG_PATH
22
+ end
23
+
24
+ def config
25
+ load_config if @config.nil? || config_file_updated?
26
+ @config
27
+ end
28
+
29
+ def load_config
30
+ @config = deep_convert_to_struct(YAML.load_file(CONFIG_PATH))
31
+ @last_loaded_time = File.mtime(CONFIG_PATH)
32
+ rescue Errno::ENOENT
33
+ raise "Configuration file not found: #{CONFIG_PATH}"
34
+ rescue Psych::SyntaxError => e
35
+ raise "YAML syntax error occurred while parsing #{CONFIG_PATH}: #{e.message}"
36
+ end
37
+
38
+ private
39
+
40
+ def config_file_updated?
41
+ File.mtime(CONFIG_PATH) > @last_loaded_time
42
+ end
43
+
44
+ # Recursively convert hash to Struct and add crud_file_path method
45
+ def deep_convert_to_struct(hash)
46
+ struct_class = Struct.new(*hash.keys.map(&:to_sym)) do
47
+ def crud_file_path
48
+ File.join(base_dir, crud_file.file_name)
49
+ end
50
+ end
51
+ struct_class.new(*hash.values.map { |value| value.is_a?(Hash) ? deep_convert_to_struct(value) : value })
52
+ end
53
+ end
54
+ end
55
+ end
56
+ 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.config
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.config
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.config.crud_file.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.config.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.config.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