rails-excel-reporter 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,210 @@
1
+ require 'caxlsx'
2
+ require 'tempfile'
3
+ require 'ostruct'
4
+ require 'stringio'
5
+ require_relative 'styling'
6
+ require_relative 'streaming'
7
+
8
+ module RailsExcelReporter
9
+ class Base
10
+ include Styling
11
+ include Streaming
12
+
13
+ attr_reader :collection, :worksheet_name, :progress_callback
14
+
15
+ def initialize(collection, worksheet_name: nil, &progress_callback)
16
+ @collection = collection
17
+ @worksheet_name = worksheet_name || default_worksheet_name
18
+ @progress_callback = progress_callback
19
+ @rendered = false
20
+ end
21
+
22
+ def self.attributes(*attrs)
23
+ if attrs.empty?
24
+ @attributes ||= []
25
+ else
26
+ @attributes = attrs.map do |attr|
27
+ case attr
28
+ when Symbol
29
+ { name: attr, header: attr.to_s.humanize }
30
+ when Hash
31
+ symbolize_hash_keys(attr)
32
+ else
33
+ { name: attr.to_sym, header: attr.to_s.humanize }
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ def self.symbolize_hash_keys(hash)
40
+ result = {}
41
+ hash.each do |key, value|
42
+ result[key.to_sym] = value
43
+ end
44
+ result
45
+ end
46
+
47
+ def self.attribute(name, options = {})
48
+ @attributes ||= []
49
+ @attributes << { name: name.to_sym, header: options[:header] || name.to_s.humanize }
50
+ end
51
+
52
+ def self.inherited(subclass)
53
+ super
54
+ subclass.instance_variable_set(:@attributes, @attributes.dup) if @attributes
55
+ end
56
+
57
+ def attributes
58
+ self.class.attributes
59
+ end
60
+
61
+ def file
62
+ render unless @rendered
63
+ @tempfile
64
+ end
65
+
66
+ def to_xlsx
67
+ render unless @rendered
68
+ @tempfile.rewind
69
+ @tempfile.read
70
+ end
71
+
72
+ def save_to(path)
73
+ File.open(path, 'wb') do |file|
74
+ file.write(to_xlsx)
75
+ end
76
+ end
77
+
78
+ def filename
79
+ timestamp = Time.now.strftime(RailsExcelReporter.config.date_format).gsub('-', '_')
80
+ "#{worksheet_name.parameterize}_report_#{timestamp}.xlsx"
81
+ end
82
+
83
+ def stream
84
+ StringIO.new(to_xlsx)
85
+ end
86
+
87
+ def to_h
88
+ {
89
+ worksheet_name: worksheet_name,
90
+ attributes: attributes,
91
+ data: collection_data,
92
+ collection_size: collection_size,
93
+ streaming: should_stream?
94
+ }
95
+ end
96
+
97
+ def before_render; end
98
+
99
+ def after_render; end
100
+
101
+ def before_row(object); end
102
+
103
+ def after_row(object); end
104
+
105
+ private
106
+
107
+ def default_worksheet_name
108
+ if self.class.name
109
+ self.class.name.demodulize.underscore.humanize
110
+ else
111
+ 'Report'
112
+ end
113
+ end
114
+
115
+ def render
116
+ validate_attributes!
117
+
118
+ before_render
119
+
120
+ @tempfile = Tempfile.new([filename.gsub('.xlsx', ''), '.xlsx'],
121
+ RailsExcelReporter.config.temp_directory)
122
+
123
+ package = ::Axlsx::Package.new
124
+ workbook = package.workbook
125
+
126
+ worksheet = workbook.add_worksheet(name: worksheet_name)
127
+
128
+ add_headers(worksheet)
129
+ add_data_rows(worksheet)
130
+
131
+ package.serialize(@tempfile.path)
132
+
133
+ @rendered = true
134
+
135
+ after_render
136
+ end
137
+
138
+ def validate_attributes!
139
+ raise 'No attributes defined. Use `attributes` class method to define columns.' if attributes.empty?
140
+ end
141
+
142
+ def add_headers(worksheet)
143
+ header_values = attributes.map { |attr| attr[:header] }
144
+ header_style = build_caxlsx_style(get_header_style)
145
+
146
+ if header_style.any?
147
+ style_id = worksheet.workbook.styles.add_style(header_style)
148
+ worksheet.add_row(header_values, style: style_id)
149
+ else
150
+ worksheet.add_row(header_values)
151
+ end
152
+
153
+ worksheet.auto_filter = "A1:#{column_letter(attributes.size)}1"
154
+ end
155
+
156
+ def add_data_rows(worksheet)
157
+ with_progress_tracking do |object, _progress|
158
+ before_row(object)
159
+
160
+ row_values = attributes.map do |attr|
161
+ get_attribute_value(object, attr[:name])
162
+ end
163
+
164
+ row_styles = attributes.map do |attr|
165
+ style_options = build_caxlsx_style(get_column_style(attr[:name]))
166
+ worksheet.workbook.styles.add_style(style_options) if style_options.any?
167
+ end
168
+
169
+ worksheet.add_row(row_values, style: row_styles)
170
+
171
+ after_row(object)
172
+ end
173
+ end
174
+
175
+ def get_attribute_value(object, attribute_name)
176
+ if respond_to?(attribute_name)
177
+ @object = object
178
+ result = send(attribute_name)
179
+ @object = nil
180
+ result
181
+ elsif object.respond_to?(attribute_name)
182
+ object.send(attribute_name)
183
+ elsif object.respond_to?(:[])
184
+ object[attribute_name] || object[attribute_name.to_s]
185
+ end
186
+ end
187
+
188
+ attr_reader :object
189
+
190
+ def collection_data
191
+ @collection_data ||= stream_data.map do |item|
192
+ attributes.map do |attr|
193
+ get_attribute_value(item, attr[:name])
194
+ end
195
+ end
196
+ end
197
+
198
+ def column_letter(column_number)
199
+ result = ''
200
+ while column_number > 0
201
+ column_number -= 1
202
+ result = ((column_number % 26) + 65).chr + result
203
+ column_number /= 26
204
+ end
205
+ result
206
+ end
207
+ end
208
+
209
+ ReportBase = Base
210
+ end
@@ -0,0 +1,40 @@
1
+ module RailsExcelReporter
2
+ class Configuration
3
+ attr_accessor :default_styles, :date_format, :streaming_threshold, :temp_directory
4
+
5
+ def initialize
6
+ @default_styles = {
7
+ header: {
8
+ bg_color: '4472C4',
9
+ fg_color: 'FFFFFF',
10
+ bold: true,
11
+ border: { style: :thin, color: '000000' }
12
+ },
13
+ cell: {
14
+ border: { style: :thin, color: 'CCCCCC' }
15
+ }
16
+ }
17
+ @date_format = '%Y-%m-%d'
18
+ @streaming_threshold = 1000
19
+ @temp_directory = nil
20
+ end
21
+
22
+ def temp_directory
23
+ @temp_directory || Dir.tmpdir
24
+ end
25
+ end
26
+
27
+ class << self
28
+ def configuration
29
+ @configuration ||= Configuration.new
30
+ end
31
+
32
+ def configure
33
+ yield(configuration)
34
+ end
35
+
36
+ def config
37
+ configuration
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,34 @@
1
+ module RailsExcelReporter
2
+ module ControllerHelpers
3
+ def send_excel_report(report, options = {})
4
+ filename = options[:filename] || report.filename
5
+ disposition = options[:disposition] || 'attachment'
6
+
7
+ send_data(
8
+ report.to_xlsx,
9
+ filename: filename,
10
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
11
+ disposition: disposition
12
+ )
13
+ end
14
+
15
+ def stream_excel_report(report, options = {})
16
+ filename = options[:filename] || report.filename
17
+
18
+ response.headers['Content-Type'] = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
19
+ response.headers['Content-Disposition'] = "attachment; filename=\"#{filename}\""
20
+ response.headers['Content-Transfer-Encoding'] = 'binary'
21
+ response.headers['Last-Modified'] = Time.now.httpdate
22
+
23
+ self.response_body = report.stream
24
+ end
25
+
26
+ def excel_report_response(report, options = {})
27
+ if report.should_stream?
28
+ stream_excel_report(report, options)
29
+ else
30
+ send_excel_report(report, options)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,35 @@
1
+ require 'rails/railtie'
2
+
3
+ module RailsExcelReporter
4
+ class Railtie < Rails::Railtie
5
+ initializer 'rails_excel_reporter.configure_rails_initialization' do |app|
6
+ app.config.paths.add 'app/reports', eager_load: true
7
+ end
8
+
9
+ initializer 'rails_excel_reporter.include_controller_helpers' do
10
+ ActiveSupport.on_load(:action_controller) do
11
+ include RailsExcelReporter::ControllerHelpers
12
+ end
13
+ end
14
+
15
+ config.after_initialize do
16
+ if defined?(Rails.application) && Rails.application
17
+ begin
18
+ reports_path = Rails.root.join('app/reports')
19
+
20
+ if Rails.application.paths['app/reports']
21
+ unless Rails.application.paths['app/reports'].paths.include?(reports_path.to_s)
22
+ Rails.application.paths['app/reports'] << reports_path.to_s
23
+ end
24
+
25
+ if Rails.application.paths['app/reports'].respond_to?(:eager_load!)
26
+ Rails.application.paths['app/reports'].eager_load!
27
+ end
28
+ end
29
+ rescue StandardError => e
30
+ Rails.logger.warn("RailsExcelReporter: Failed to configure app/reports path: #{e.message}") if Rails.logger
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,81 @@
1
+ module RailsExcelReporter
2
+ module Streaming
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ end
6
+
7
+ module ClassMethods
8
+ def streaming_threshold
9
+ @streaming_threshold || RailsExcelReporter.config.streaming_threshold
10
+ end
11
+
12
+ def streaming_threshold=(value)
13
+ @streaming_threshold = value
14
+ end
15
+ end
16
+
17
+ def should_stream?
18
+ collection_size >= self.class.streaming_threshold
19
+ end
20
+
21
+ def collection_size
22
+ return @collection_size if defined?(@collection_size)
23
+
24
+ @collection_size = if @collection.respond_to?(:count)
25
+ @collection.count
26
+ elsif @collection.respond_to?(:size)
27
+ @collection.size
28
+ elsif @collection.respond_to?(:length)
29
+ @collection.length
30
+ else
31
+ @collection.to_a.size
32
+ end
33
+ end
34
+
35
+ def stream_data(&block)
36
+ return enum_for(:stream_data) unless block_given?
37
+
38
+ if should_stream?
39
+ stream_large_dataset(&block)
40
+ else
41
+ stream_small_dataset(&block)
42
+ end
43
+ end
44
+
45
+ def with_progress_tracking
46
+ return enum_for(:with_progress_tracking) unless block_given?
47
+
48
+ total = collection_size
49
+ current = 0
50
+
51
+ stream_data do |item|
52
+ current += 1
53
+ progress = OpenStruct.new(current: current, total: total, percentage: (current.to_f / total * 100).round(2))
54
+
55
+ @progress_callback&.call(progress)
56
+
57
+ yield item, progress
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def stream_large_dataset(&block)
64
+ if @collection.respond_to?(:find_each)
65
+ @collection.find_each(batch_size: 1000, &block)
66
+ elsif @collection.respond_to?(:each)
67
+ @collection.each(&block)
68
+ else
69
+ @collection.to_a.each(&block)
70
+ end
71
+ end
72
+
73
+ def stream_small_dataset(&block)
74
+ if @collection.respond_to?(:each)
75
+ @collection.each(&block)
76
+ else
77
+ @collection.to_a.each(&block)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,87 @@
1
+ module RailsExcelReporter
2
+ module Styling
3
+ def self.included(base)
4
+ base.extend(ClassMethods)
5
+ end
6
+
7
+ module ClassMethods
8
+ def style(target, options = {})
9
+ @styles ||= {}
10
+ @styles[target.to_sym] = options
11
+ end
12
+
13
+ def styles
14
+ @styles ||= {}
15
+ end
16
+
17
+ def inherited(subclass)
18
+ super
19
+ subclass.instance_variable_set(:@styles, @styles.dup) if @styles
20
+ end
21
+ end
22
+
23
+ def apply_style(worksheet, cell_range, style_name)
24
+ style_options = self.class.styles[style_name.to_sym] || {}
25
+ return unless style_options.any?
26
+
27
+ worksheet.add_style(cell_range, style_options)
28
+ end
29
+
30
+ def build_caxlsx_style(style_options)
31
+ caxlsx_options = {}
32
+
33
+ caxlsx_options[:bg_color] = style_options[:bg_color] if style_options[:bg_color]
34
+
35
+ caxlsx_options[:fg_color] = style_options[:fg_color] if style_options[:fg_color]
36
+
37
+ caxlsx_options[:b] = style_options[:bold] if style_options[:bold]
38
+
39
+ caxlsx_options[:i] = style_options[:italic] if style_options[:italic]
40
+
41
+ caxlsx_options[:alignment] = style_options[:alignment] if style_options[:alignment]
42
+
43
+ caxlsx_options[:border] = style_options[:border] if style_options[:border]
44
+
45
+ caxlsx_options[:sz] = style_options[:font_size] if style_options[:font_size]
46
+
47
+ caxlsx_options[:font_name] = style_options[:font_name] if style_options[:font_name]
48
+
49
+ caxlsx_options
50
+ end
51
+
52
+ def merge_styles(*style_names)
53
+ merged = {}
54
+ style_names.each do |style_name|
55
+ style_options = self.class.styles[style_name.to_sym] || {}
56
+ merged = deep_merge_hashes(merged, style_options)
57
+ end
58
+ merged
59
+ end
60
+
61
+ def get_column_style(column_name)
62
+ column_style = self.class.styles[column_name.to_sym] || {}
63
+ default_style = RailsExcelReporter.config.default_styles[:cell] || {}
64
+ deep_merge_hashes(default_style, column_style)
65
+ end
66
+
67
+ def get_header_style
68
+ header_style = self.class.styles[:header] || {}
69
+ default_style = RailsExcelReporter.config.default_styles[:header] || {}
70
+ deep_merge_hashes(default_style, header_style)
71
+ end
72
+
73
+ private
74
+
75
+ def deep_merge_hashes(hash1, hash2)
76
+ result = hash1.dup
77
+ hash2.each do |key, value|
78
+ result[key] = if result[key].is_a?(Hash) && value.is_a?(Hash)
79
+ deep_merge_hashes(result[key], value)
80
+ else
81
+ value
82
+ end
83
+ end
84
+ result
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,3 @@
1
+ module RailsExcelReporter
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,19 @@
1
+ require 'active_support/core_ext/string'
2
+ require 'active_support/core_ext/hash'
3
+ require 'active_support/core_ext/time'
4
+ require 'tmpdir'
5
+
6
+ module RailsExcelReporter
7
+ class Error < StandardError; end
8
+ class AttributeNotFoundError < Error; end
9
+ class InvalidConfigurationError < Error; end
10
+ end
11
+
12
+ require 'rails_excel_reporter/version'
13
+ require 'rails_excel_reporter/configuration'
14
+ require 'rails_excel_reporter/styling'
15
+ require 'rails_excel_reporter/streaming'
16
+ require 'rails_excel_reporter/base'
17
+ require 'rails_excel_reporter/controller_helpers'
18
+
19
+ require 'rails_excel_reporter/railtie' if defined?(Rails)
@@ -0,0 +1,36 @@
1
+ require_relative 'lib/rails_excel_reporter/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = 'rails-excel-reporter'
5
+ spec.version = RailsExcelReporter::VERSION
6
+ spec.authors = ['Elí Sebastian Herrera Aguilar']
7
+ spec.email = ['esrbastianherrera@gmail.com']
8
+
9
+ spec.summary = 'Generate Excel reports (.xlsx) in Rails with a simple DSL'
10
+ spec.description = 'A Ruby gem that integrates seamlessly with Ruby on Rails to generate Excel reports using a simple DSL. Features include streaming, styling, callbacks, and Rails helpers.'
11
+ spec.homepage = 'https://github.com/EliSebastian/rails-excel-reporter.git'
12
+ spec.license = 'MIT'
13
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.7.0')
14
+
15
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
16
+ spec.metadata['homepage_uri'] = spec.homepage
17
+ spec.metadata['source_code_uri'] = 'https://github.com/EliSebastian/rails-excel-reporter.git'
18
+ spec.metadata['changelog_uri'] = 'https://github.com/EliSebastian/rails-excel-reporter.git/blob/main/CHANGELOG.md'
19
+
20
+ spec.files = Dir['{lib,spec}/**/*', '*.md', '*.gemspec', 'Gemfile*']
21
+ spec.bindir = 'exe'
22
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
+ spec.require_paths = ['lib']
24
+
25
+ spec.add_dependency 'activesupport', '~> 8.0'
26
+ spec.add_dependency 'caxlsx', '~> 4.0'
27
+ spec.add_dependency 'rails', '~> 8.0'
28
+
29
+ spec.add_development_dependency 'pry', '~> 0.14'
30
+ spec.add_development_dependency 'rspec', '~> 3.0'
31
+ spec.add_development_dependency 'rspec-rails', '~> 5.0'
32
+ spec.add_development_dependency 'rubocop', '~> 1.0'
33
+ spec.add_development_dependency 'simplecov', '~> 0.21'
34
+ spec.add_development_dependency 'sqlite3', '~> 1.4'
35
+ spec.add_development_dependency 'yard', '~> 0.9'
36
+ end