magic-report 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 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: []