magic-report 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b11733b1128011acfba29bf24cbf2846ce42e226aeccadfcdfc6ce713c2b1b3e
4
+ data.tar.gz: 0dbec29ea7c05acf3744fc1a62d4b200455a88926d1e938826243df683d9ce93
5
+ SHA512:
6
+ metadata.gz: ad63138a1fe01308e5ddfef0f50d3feb04ae5dd49e9fe8c2e81dcac6ed5f376daf32de12baf1912695c798ade584d96652d7c1c0a0fc6f801992fd41c0a09bce
7
+ data.tar.gz: 2fff4d4f90864fded9a18846e718fbc82671907f106ce16c5240df94d57d2e92c27c2f7e423d02da1ee88a93b4f49a21db201636ffc04bb1b9c020c04a74d562
data/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # MagicReport
2
+
3
+ An easy way to export data to CSV
4
+
5
+ [![Build Status](https://github.com/thefaded/magic-report/workflows/test/badge.svg?branch=master)](https://github.com/thefaded/magic-report/actions)
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application’s Gemfile:
10
+
11
+ ```ruby
12
+ gem "magic-operation"
13
+ ```
14
+
15
+ ## Getting Started
16
+
17
+ This gem provides an ActiveRecord-like DSL to create CSV reports. One of the common use cases is when you have nested data.
18
+
19
+ ```ruby
20
+ class User < MagicReport::Report
21
+ field :id
22
+ field :is_admin, ->(user) { user.is_admin ? "Yes" : "No" }
23
+ end
24
+
25
+ report = User.new
26
+ report.process(UserModel.first)
27
+
28
+ # You can also `process` on a collection
29
+ # report.process(User.all.limit(50))
30
+
31
+ report.as_csv
32
+ ```
33
+
34
+ The example above is basic - you have a User model and you want to export the two fields `id` and `is_admin`.
35
+ The users of your application may not particularly like the naming `true` or `false` since these are more technical terms, that's why you can pass an additional block to the field.
36
+
37
+ Also, for each report you must provide locales file:
38
+
39
+ ```yaml
40
+ en:
41
+ magic_report:
42
+ headings:
43
+ user:
44
+ id: ID
45
+ is_admin: Admin?
46
+ ```
47
+
48
+ CSV will be
49
+ | ID | Admin? |
50
+ | ---- | -------- |
51
+ | 123 | Yes |
52
+ | 222 | No |
53
+
54
+ ## Nested
55
+
56
+ Let's look at a more complex example. Now we have a user model with an address and several cars.
57
+
58
+ ```ruby
59
+ class User < MagicReport::Report
60
+ field :id
61
+ field :is_admin, ->(user) { user.is_admin ? "Yes" : "No" }
62
+
63
+ has_one :shipping_address, class: Address, prefix: -> { t("shipping_address") }
64
+ has_one :billing_address, class: Address, prefix: -> { t("billing_address") }
65
+
66
+ has_many :cars, class: Car, prefix: -> { t("car") }
67
+ end
68
+
69
+ class Address < MagicReport::Report
70
+ fields :address_line_1, :city
71
+ end
72
+
73
+ class Car < MagicReport::Report
74
+ field :name
75
+ end
76
+ ```
77
+
78
+ Because we have explicitly said that the user `has_many :cars`, the number of lines in the CSV will be equal to the number of cars.
79
+
80
+ ```yaml
81
+ en:
82
+ magic_report:
83
+ headings:
84
+ user:
85
+ id: ID
86
+ is_admin: Admin?
87
+ address: User address
88
+ cars: Car
89
+ shipping_address: Shipping address
90
+ billing_address: Billing address
91
+ car:
92
+ name: Name
93
+ address:
94
+ address_line_1: Line 1
95
+ city: City
96
+ ```
97
+
98
+ CSV will be
99
+ | ID | Admin? | Shipping address Line 1 | Shipping address City | Billing address Line 1 | Billing address City | Car Name |
100
+ | ---- | -------- | ------------------- | ----------------- | -------- | -------- | ------- |
101
+ | 123 | Yes | 5th Ave | NY | Chester st | San Francisco | Lexus |
102
+ | 123 | Yes | 5th Ave | NY | Chester st | San Francisco | BMW |
103
+
104
+ ### Using with blocks
105
+
106
+ The above example can be rewritten using blocks instead of class
107
+
108
+ ```ruby
109
+ class User < MagicReport::Report
110
+ field :id
111
+ field :is_admin, ->(user) { user.is_admin ? "Yes" : "No" }
112
+
113
+ # You should always provide `name` option if you're using block instead of class
114
+ # From this option will be used locales for `address_line_1` and `city`
115
+ has_one :shipping_address, name: :address, prefix: -> { t("shipping_address") } do
116
+ fields :address_line_1, :city
117
+ end
118
+ # Prefix locale is taken from `user.billing_address`
119
+ has_one :billing_address, name: :address, prefix: -> { t("billing_address") } do
120
+ fields :address_line_1, :city
121
+ end
122
+
123
+ has_many :cars, name: :car, prefix: -> { t("car") } do
124
+ field :name
125
+ end
126
+ end
127
+ ```
128
+
129
+ ## Contributing
130
+
131
+ Everyone is encouraged to help improve this project. Here are a few ways you can help:
132
+
133
+ - [Report bugs](https://github.com/thefaded/magic-report/issues)
134
+ - Fix bugs and [submit pull requests](https://github.com/thefaded/magic-report/pulls)
135
+ - Write, clarify, or fix documentation
136
+ - Suggest or add new features
137
+
138
+ To get started with development and testing, check out the [Contributing Guide](CONTRIBUTING.md).
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MagicReport
4
+ class Report
5
+ module ClassHelpers
6
+ def name_from_class
7
+ ::MagicReport::Utils.underscore(self.class.name)
8
+ end
9
+
10
+ def fields_from_class
11
+ self.class.instance_variable_get(:@fields) || []
12
+ end
13
+
14
+ def has_one_from_class
15
+ self.class.instance_variable_get(:@has_one) || []
16
+ end
17
+
18
+ def has_many_from_class
19
+ self.class.instance_variable_get(:@has_many) || []
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MagicReport
4
+ class Report
5
+ module Configuration
6
+ class Field
7
+ attr_reader :key, :processor
8
+
9
+ def initialize(key:, processor: nil)
10
+ @key = key
11
+ @processor = processor
12
+ end
13
+
14
+ def process(entity)
15
+ processor ? processor.call(entity) : entity.send(key)
16
+ end
17
+ end
18
+
19
+ class HasOne
20
+ attr_reader :klass, :opts, :prefix, :key
21
+
22
+ def initialize(klass:, opts:, key:)
23
+ @klass = klass
24
+ @prefix = opts[:prefix]
25
+ @key = key
26
+ @opts = opts
27
+ end
28
+
29
+ def process_entity(entity)
30
+ report.process(entity)
31
+ end
32
+
33
+ def report
34
+ @report ||= init_report
35
+ end
36
+
37
+ private
38
+
39
+ # { class: Exports::Supplier, prefix: lambda }
40
+
41
+ def init_report
42
+ klass.new(report_params)
43
+ end
44
+
45
+ def report_opts
46
+ opts
47
+ end
48
+
49
+ def report_params
50
+ opts.reject { |k, v| %i[class].include? k }.merge(nested_field: key)
51
+ end
52
+ end
53
+
54
+ class HasMany < HasOne; end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module MagicReport
6
+ class Report
7
+ class Csv
8
+ attr_reader :report, :file, :csv
9
+
10
+ def initialize(report)
11
+ @report = report
12
+ @file = Tempfile.new
13
+ @csv = ::CSV.new(@file, write_headers: true)
14
+ end
15
+
16
+ def generate
17
+ write_headers
18
+
19
+ report.result.each do |row|
20
+ row.to_h.each { |nested_row| csv << nested_row.values }
21
+ end
22
+ # ensure
23
+ # file.close
24
+ end
25
+
26
+ def unlink
27
+ file.unlink
28
+ end
29
+
30
+ private
31
+
32
+ def write_headers
33
+ csv << report.headings
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MagicReport
4
+ class Report
5
+ class Process
6
+ attr_reader :report
7
+
8
+ def initialize(report)
9
+ @report = report
10
+ end
11
+
12
+ def call(input)
13
+ if input.is_a? Enumerable
14
+ input.map do |entity|
15
+ process_entity(entity)
16
+ end
17
+ else
18
+ process_entity(input)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def process_entity(entity)
25
+ row = Row.new
26
+
27
+ process_fields(entity, row)
28
+ process_has_one(entity, row)
29
+ process_has_many(entity, row)
30
+
31
+ row
32
+ end
33
+
34
+ def process_fields(entity, row)
35
+ report.fields.each do |field|
36
+ row.add(field: report.resolve_path(field.key), value: field.process(entity))
37
+ end
38
+ end
39
+
40
+ def process_has_one(entity, row)
41
+ report.has_one.each do |association|
42
+ inner_row = association.process_entity(entity.send(association.key))
43
+
44
+ row.add_inner_row(field: report.resolve_path(association.key), row: inner_row)
45
+ end
46
+ end
47
+
48
+ def process_has_many(entity, row)
49
+ report.has_many.each do |association|
50
+ entity.send(association.key).each do |entity|
51
+ nested_row = association.process_entity(entity)
52
+
53
+ row.add_nested_row(field: report.resolve_path(association.key), row: nested_row)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MagicReport
4
+ class Report
5
+ class Row
6
+ attr_accessor :data, :inner_rows, :nested_rows
7
+
8
+ def initialize
9
+ @data = {}
10
+ @inner_rows = {}
11
+ @nested_rows = {}
12
+ end
13
+
14
+ def add(field:, value:)
15
+ data[field] = value
16
+ end
17
+
18
+ def add_inner_row(field:, row:)
19
+ inner_rows[field] = row
20
+ end
21
+
22
+ def add_nested_row(field:, row:)
23
+ nested_rows[field] ||= []
24
+ nested_rows[field].push(row)
25
+ end
26
+
27
+ def values
28
+ data.values
29
+ end
30
+
31
+ def complex?
32
+ nested_rows.any?
33
+ end
34
+
35
+ def each_nested_row(&block)
36
+ nested_rows.keys.flat_map do |key|
37
+ nested_rows[key].map do |nested_row|
38
+ block.call(nested_row)
39
+ end
40
+ end
41
+ end
42
+
43
+ def each_inner_row(&block)
44
+ inner_rows.values.map do |inner_row|
45
+ block.call(inner_row)
46
+ end
47
+ end
48
+
49
+ def to_h
50
+ @to_h ||= begin
51
+ original_hash = inner_rows.any? ? data.merge(each_inner_row { |inner_row| inner_row.to_h }.reduce({}, :merge)) : data
52
+
53
+ if complex?
54
+ each_nested_row do |nested_row|
55
+ original_hash.merge(nested_row.to_h)
56
+ end
57
+ else
58
+ original_hash
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MagicReport
4
+ class Report
5
+ include ClassHelpers
6
+
7
+ attr_reader :fields, :has_one, :has_many, :name, :nested_field, :prefix, :result
8
+
9
+ def initialize(fields: nil, has_one: nil, has_many: nil, name: nil, prefix: nil, nested_field: nil)
10
+ @fields = fields || fields_from_class
11
+ @has_one = has_one || has_one_from_class
12
+ @has_many = has_many || has_many_from_class
13
+ @name = name || name_from_class
14
+
15
+ @prefix = prefix
16
+ @nested_field = nested_field
17
+ end
18
+
19
+ def process(input)
20
+ @result = ::MagicReport::Report::Process.new(self).call(input)
21
+ end
22
+
23
+ def as_csv
24
+ @as_csv ||= begin
25
+ csv = ::MagicReport::Report::Csv.new(self)
26
+ csv.generate
27
+
28
+ csv
29
+ end
30
+ end
31
+
32
+ def headings
33
+ @headings ||= (fields.map { |field| t(field.key) } + has_one.map { |association| association.report.headings } + has_many.map { |association| association.report.headings }).flatten
34
+ end
35
+
36
+ class << self
37
+ def t(key)
38
+ ::MagicReport::Utils.t(name: name, key: key)
39
+ end
40
+
41
+ def fields(*attrs)
42
+ @fields ||= []
43
+
44
+ Types::SymbolArray[attrs].each do |key|
45
+ @fields << Configuration::Field.new(key: key)
46
+ end
47
+ end
48
+
49
+ def field(*attrs)
50
+ key, processor = attrs
51
+
52
+ @fields ||= []
53
+ @fields << Configuration::Field.new(key: key, processor: processor)
54
+ end
55
+
56
+ def has_one(attribute, opts = {}, &block)
57
+ @has_one ||= []
58
+
59
+ coerced_attribute = Types::Coercible::Symbol[attribute]
60
+
61
+ klass = ::MagicReport::Utils.derive_class(opts, &block)
62
+
63
+ if (prefix = opts[:prefix])
64
+ opts[:prefix] = new.instance_exec(&prefix)
65
+ end
66
+
67
+ @has_one << Configuration::HasOne.new(klass: klass, opts: opts, key: coerced_attribute)
68
+ end
69
+
70
+ def has_many(attribute, opts = {}, &block)
71
+ @has_many ||= []
72
+
73
+ coerced_attribute = Types::Coercible::Symbol[attribute]
74
+
75
+ klass = ::MagicReport::Utils.derive_class(opts, &block)
76
+
77
+ if (prefix = opts[:prefix])
78
+ opts[:prefix] = new.instance_exec(&prefix)
79
+ end
80
+
81
+ @has_many << Configuration::HasMany.new(klass: klass, opts: opts, key: coerced_attribute)
82
+ end
83
+ end
84
+
85
+ def resolve_path(key)
86
+ nested_field ? "#{nested_field}.#{key}".to_sym : key
87
+ end
88
+
89
+ private
90
+
91
+ def t(key)
92
+ translated = ::MagicReport::Utils.t(name: name, key: key)
93
+
94
+ prefix ? "#{prefix} #{translated}" : translated
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MagicReport
4
+ module Utils
5
+ def underscore(klass)
6
+ klass.gsub(/::/, "/")
7
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
8
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
9
+ .tr("-", "_")
10
+ .downcase
11
+ end
12
+
13
+ def derive_class(opts, &block)
14
+ if block
15
+ raise "name option must be provided" unless opts[:name]
16
+
17
+ cloned_klass = ::MagicReport::Report.clone
18
+ cloned_klass.class_eval(&block)
19
+ cloned_klass
20
+ else
21
+ opts[:class]
22
+ end
23
+ end
24
+
25
+ # @param name is the report name
26
+ # @key is a field
27
+ def t(name:, key:)
28
+ I18n.translate!("magic_report.headings.#{name}.#{key}")
29
+ end
30
+
31
+ module_function :underscore
32
+ module_function :derive_class
33
+ module_function :t
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MagicReport
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-types"
4
+ require "i18n"
5
+
6
+ require "magic_report/version"
7
+
8
+ module MagicReport
9
+ class Error < StandardError; end
10
+
11
+ module Types
12
+ include ::Dry.Types()
13
+
14
+ SymbolArray = Array.of(Types::Coercible::Symbol)
15
+ end
16
+
17
+ require "magic_report/utils"
18
+
19
+ require "magic_report/report/class_helpers"
20
+ require "magic_report/report/configuration"
21
+ require "magic_report/report/process"
22
+ require "magic_report/report/row"
23
+ require "magic_report/report/csv"
24
+ require "magic_report/report"
25
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: magic-report
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dan Pankratev
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-10-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-types
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: i18n
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description:
42
+ email: thepoddubstep@gmail.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - README.md
48
+ - lib/magic_report.rb
49
+ - lib/magic_report/report.rb
50
+ - lib/magic_report/report/class_helpers.rb
51
+ - lib/magic_report/report/configuration.rb
52
+ - lib/magic_report/report/csv.rb
53
+ - lib/magic_report/report/process.rb
54
+ - lib/magic_report/report/row.rb
55
+ - lib/magic_report/utils.rb
56
+ - lib/magic_report/version.rb
57
+ homepage: https://github.com/thefaded/magic-report
58
+ licenses:
59
+ - MIT
60
+ metadata: {}
61
+ post_install_message:
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '2.6'
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 3.1.6
77
+ signing_key:
78
+ specification_version: 4
79
+ summary: An easy way to export data to CSV
80
+ test_files: []