easy_exports 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9e4470d18b361781bf745a2ca0dc563b82ccf603b456a55ad75a96113d3975c
4
- data.tar.gz: 2576218f1f50c7743f1ef0a9cbada4e4ebe452f20dfb12dec4613aedcb0f23d4
3
+ metadata.gz: 99d64117b05b63ec1f0cd4ef5b8b6a7ec3d653087678d9b4831edb87c8dd61c4
4
+ data.tar.gz: 884619c1077d8ff642ceb1466f4c9ffcbb50bac95277b5bf7765303f53d666f3
5
5
  SHA512:
6
- metadata.gz: 401cf20c2ee26d378b6f31d02acb64e34bf49761c417f65e0954d9132f45855feb8fcc13be73948cbdbb5e1b941df1e30363a23828ed14bdec8601e1a13c4309
7
- data.tar.gz: 2c5e04b944b9551843802c46ce131f5d0c7ff0e732d1db1f3fc07ef93a9e2227b67771e3a287da54b123d8b4e254e20206e41b9f8a70728cf79f4a3d287fb835
6
+ metadata.gz: e843ce5e1aa5edab56616992a16d989952c52343a854619d0fb46715617af59a2e4ca641d69bb8dc243d2aea63b7bdae42a164e2e39e3ce9784e471ff04f3054
7
+ data.tar.gz: 4915abc76083e2e755e954699b788e4d5debaef66cd764c42af7d85eb2dc56dafff68589ca81c38a71fbdb64962c8a891c1291781f8591af20702f4be8f463a4
data/README.md CHANGED
@@ -1,8 +1,5 @@
1
1
  # EasyExports
2
- Short description and motivation.
3
-
4
- ## Usage
5
- How to use my plugin.
2
+ EasyExports is a rails ActiveRecord ORM extension dedicated to streamlining and simplifying the model data export process by eliminating common complexities.
6
3
 
7
4
  ## Installation
8
5
  Add this line to your application's Gemfile:
@@ -20,9 +17,348 @@ Or install it yourself as:
20
17
  ```bash
21
18
  $ gem install easy_exports
22
19
  ```
20
+ ## Usage
21
+ Upon installation, EasyExports seamlessly integrates with ```ActiveRecord::Base```, granting all models immediate access to its efficient export methods.
22
+
23
+ ### Generating Exportable Attributes
24
+
25
+ Retrieve exportable attributes using the `exportable_attributes` method. This method retrieves attributes of the model itself and those of all its associations.
26
+
27
+ ```ruby
28
+ # Example Models and Exportable Attributes
29
+
30
+ # User model with columns: first_name, last_name, created_at, updated_at
31
+ class User < ApplicationRecord
32
+ has_and_belongs_to_many :emails
33
+ has_many :phones
34
+ end
35
+
36
+ # Exportable attributes for the User model
37
+ User.exportable_attributes
38
+ # =>
39
+ # {
40
+ # "User" => ["id", "first name", "last name", "created at", "updated at"],
41
+ # "Emails" => ["id", "address", "created at", "updated at"],
42
+ # "Phones" => ["id", "number", "user id", "created at", "updated at"]
43
+ # }
44
+
45
+ # Phone model with columns: number, user_id, created_at, updated_at
46
+ class Phone < ApplicationRecord
47
+ belongs_to :user
48
+ end
49
+
50
+ # Exportable attributes for the Phone model
51
+ Phone.exportable_attributes
52
+ # =>
53
+ # {
54
+ # "Phone" => ["id", "number", "user id", "created at", "updated at"],
55
+ # "User" => ["id", "first name", "last name", "created at", "updated at"]
56
+ # }
57
+ ```
58
+
59
+ ### Generating Exports from Exportable Attributes
60
+
61
+ To generate exports, use the `generate_exports(exportable_attributes, ids, order:)` method.
62
+
63
+ - The `exportable_attributes` argument specifies the chosen attributes from the exportable attributes list.
64
+ - The `ids` argument is optional; provide IDs to export data for specific records.
65
+ - Omitting `ids` will trigger exports for all records of the given model.
66
+ - The `order:` keyword argument is optional; see [Ordering Exports](#ordering-exports) for details.
67
+
68
+ The method returns an `EasyExports::Export` object containing hash data from the records and a `csv_string` that can be written to a CSV file.
69
+
70
+ ```ruby
71
+ user_exportable_attributes = {"User"=>["id", "first name"], "Phones"=>["id", "number"], "Emails"=>["id", "address"]}
72
+
73
+ exports_object = User.generate_exports(user_exportable_attributes)
74
+ # => EasyExports::Export(Object)
75
+
76
+ exports_data = exports_object.data
77
+ # =>
78
+ # [
79
+ # {"user_id"=>1, "user_first_name"=>"Sydney", "phones_id"=>1, "phones_number"=>"(473) 693-8745", "emails_id"=>5, "emails_address"=>"blake_armstrong@bahringer.test"},
80
+ # {"user_id"=>1, "user_first_name"=>"Sydney", "phones_id"=>2, "phones_number"=>"594-299-0722", "emails_id"=>6, "emails_address"=>"dulce@mertz.example"},
81
+ # {"user_id"=>1, "user_first_name"=>"Sydney", "phones_id"=>3, "phones_number"=>"1-609-662-2028", "emails_id"=>nil, "emails_address"=>nil},
82
+ # {"user_id"=>2, "user_first_name"=>"Stan", "phones_id"=>4, "phones_number"=>"951-671-9548", "emails_id"=>7, "emails_address"=>"dominick@durgan.example"},
83
+ # {"user_id"=>2, "user_first_name"=>"Stan", "phones_id"=>5, "phones_number"=>"1-698-432-7489", "emails_id"=>nil, "emails_address"=>nil}
84
+ # ]
85
+
86
+ exports_csv_string = exports_object.csv_string
87
+ # =>
88
+ # "user_id,user_first_name,phones_id,phones_number,emails_id,emails_address\n
89
+ # 1,Sydney,1,(473) 693-8745,5,blake_armstrong@bahringer.test\n
90
+ # 1,Sydney,2,594-299-0722,6,dulce@mertz.example\n
91
+ # 1,Sydney,3,1-609-662-2028,,\n
92
+ # 2,Stan,4,951-671-9548,7,dominick@durgan.example\n
93
+ # 2,Stan,5,1-698-432-7489,,\n"
94
+
95
+ # Writing csv_string to a file to visualize the generated export
96
+ File.open(file_path, 'w') do |file|
97
+ file.write(exports_csv_string)
98
+ end
99
+ ```
100
+ <div align="center">
101
+ <img width="600" alt="csv_with_emails" src="https://github.com/SydDaps/easy_exports/assets/51008616/8b9df43c-419d-4ca0-8cb1-6217025b8050">
102
+ </div>
103
+
104
+ Exported CSV showcases data for:
105
+
106
+ - User "Sydney" with 3 phones and 2 emails
107
+ - User "Stan" with 2 phones and 1 email.
108
+ - The main CSV header includes association names and attribute names.
109
+
110
+
111
+ ### Ordering Exports
112
+
113
+ Control the order of rows in the generated export using the `order:` keyword argument on `generate_exports`.
114
+
115
+ - `order:` accepts any value ActiveRecord's `order` accepts — a hash, string, symbol, or array.
116
+ - When `order:` is omitted and the model has a `created_at` column, exports default to newest first (`created_at DESC`).
117
+ - When `order:` is omitted and no `created_at` column exists, no ordering is applied.
118
+
119
+ ```ruby
120
+ # Example: explicit ordering
121
+
122
+ # Order by first_name ascending
123
+ User.generate_exports(user_exportable_attributes, [], order: { first_name: :asc })
124
+
125
+ # Order by a raw SQL fragment
126
+ User.generate_exports(user_exportable_attributes, [], order: 'last_name DESC, id ASC')
127
+
128
+ # Default ordering (newest first when created_at exists)
129
+ User.generate_exports(user_exportable_attributes)
130
+ ```
131
+
132
+ For performance, records are fetched in batches of 1,000 internally while preserving the requested order across the full result set. The batch size is configurable — see [Configuration](#configuration).
133
+
134
+ ### Configuration
135
+
136
+ Configure gem-wide defaults using `EasyExports.configure`.
137
+
138
+ ```ruby
139
+ # config/initializers/easy_exports.rb
140
+ EasyExports.configure do |c|
141
+ c.batch_size = 500 # default: 1000
142
+ c.sensitive_attributes += %w[internal_note ssn] # extends the default list
143
+ c.type_formatters[:datetime] = ->(v) { v.strftime('%d/%m/%Y') }
144
+ c.csv_header_formatter = ->(key) { key.humanize.upcase } # or set to nil to disable
145
+ end
146
+ ```
147
+
148
+ - `batch_size` — number of records fetched per database round trip during export. Override per call with `generate_exports(..., batch_size: 2_000)`.
149
+ - `sensitive_attributes` — attributes auto-excluded from every export. See [Sensitive Attributes](#sensitive-attributes) for the default list and per-model opt-out.
150
+ - `type_formatters` — per-type value formatters applied before a raw value is written to CSV. See [Type Formatters](#type-formatters).
151
+ - `csv_header_formatter` — callable applied to every header in the CSV row. Defaults to `humanize.titleize` (e.g. `registration_email` → `Registration Email`). Set to `nil` to keep raw snake_case headers.
152
+
153
+ ### Sensitive Attributes
154
+
155
+ EasyExports automatically excludes commonly sensitive attributes from all exports. The default list is:
156
+
157
+ ```ruby
158
+ %w[
159
+ password_digest
160
+ encrypted_password
161
+ remember_token
162
+ reset_password_token
163
+ confirmation_token
164
+ session_token
165
+ api_key
166
+ secret_token
167
+ ]
168
+ ```
169
+
170
+ To include these on a specific model (e.g. for admin-only exports), opt in at the class level:
171
+
172
+ ```ruby
173
+ class AdminLogin < ApplicationRecord
174
+ self.include_sensitive_exportable_attributes = true
175
+ end
176
+ ```
177
+
178
+ Extend or replace the default list through [Configuration](#configuration).
179
+
180
+ ### Type Formatters
181
+
182
+ EasyExports applies type-based formatters automatically, so you don't have to write a lambda for every boolean or timestamp column.
183
+
184
+ | Type | Matches | Shipped formatter | Example output |
185
+ |------|---------|-------------------|----------------|
186
+ | `boolean` | `true` / `false` | `->(v) { v ? 'Yes' : 'No' }` | `Yes`, `No` |
187
+ | `datetime` | `Date`, `Time`, `DateTime`, `ActiveSupport::TimeWithZone` | `->(v) { v.strftime('%A, %B %-d, %Y %H:%M') }` | `Sunday, January 5, 2025 09:17` |
188
+ | `leading_zero_string` | `String` starting with `0` | `->(v) { "'#{v}" }` | `'0244867596` (preserves leading zero in spreadsheets) |
189
+ | `string` | `String` not containing `@` | *(none — opt in per model)* | — |
190
+
191
+ `nil` values always pass through untouched.
192
+
193
+ **Turn off a type formatter for a specific model:**
194
+
195
+ ```ruby
196
+ class Transaction < ApplicationRecord
197
+ disable_exportable_type_formatter :datetime # one type
198
+ disable_exportable_type_formatter :boolean, :datetime # multiple types
199
+ end
200
+ ```
201
+
202
+ **Replace a type formatter for a specific model** — apply a different format to *all* values of that type on this one model:
203
+
204
+ ```ruby
205
+ class Registration < ApplicationRecord
206
+ # Every datetime on Registration renders as "January 5, 2025" — no time portion
207
+ format_exportable_type :datetime, ->(v) { v.strftime('%B %-d, %Y') }
208
+
209
+ # Capitalize the first letter of every plain string (emails are skipped automatically)
210
+ format_exportable_type :string, ->(v) { v.sub(/^./, &:upcase) }
211
+
212
+ # Block form
213
+ format_exportable_type(:boolean) { |v| v ? '✓' : '✗' }
214
+ end
215
+ ```
216
+
217
+ **Change or disable a type formatter globally** — see [Configuration](#configuration).
218
+
219
+ ### Custom Value Formatters
220
+
221
+ For a specific attribute, override the default with your own formatter using `format_exportable_attribute`.
222
+
223
+ - Declare it on the class that owns the attribute. It applies whenever that attribute is exported, whether from the owner model directly or through an association.
224
+ - Accepts either a callable (lambda/proc) or a block.
225
+ - A custom formatter takes precedence over any default formatter for that attribute.
226
+
227
+ ```ruby
228
+ class Registration < ApplicationRecord
229
+ # Override the global datetime default with a shorter format for this attribute
230
+ format_exportable_attribute :registered_on, ->(v) { v&.strftime('%Y-%m-%d') }
231
+
232
+ # Override the global boolean default with custom glyphs
233
+ format_exportable_attribute :is_archived, ->(v) { v ? '✓' : '' }
234
+
235
+ # Block form
236
+ format_exportable_attribute(:status) { |v| v.to_s.titleize }
237
+ end
238
+ ```
239
+
240
+ **Precedence** (top wins):
241
+ 1. Per-attribute `format_exportable_attribute`
242
+ 2. Per-model `disable_default_formatter` (if disabled → raw value)
243
+ 3. Per-model `override_default_formatter` (if overridden → override wins)
244
+ 4. Global `EasyExports.default_formatters[:type]`
245
+ 5. Raw value
246
+
247
+ ### Exportable Attributes Aliases
248
+
249
+ Configure an alternative association name for exportable attributes using the `exportable_association_aliases(aliases)` model method.
250
+
251
+ - Invoke this method below all association definitions.
252
+ - `aliases` should be a hash in the pattern: `{valid_association_name or model_name: "alternative_name"}`.
253
+ - Ensure all hash arguments are snake-cased.
254
+
255
+ ```ruby
256
+ # Example Model with exportable_association_aliases
257
+
258
+ # User model with columns: first_name, last_name, created_at, updated_at
259
+ class User < ApplicationRecord
260
+ has_many :phones
261
+
262
+ exportable_association_aliases phones: :mobile_phones
263
+ end
264
+
265
+ # Exportable attributes for the User model will now be
266
+ User.exportable_attributes
267
+ # =>
268
+ # {
269
+ # "User" => ["id", "first name", "last name", "created at", "updated at"],
270
+ # "Mobile phones" => ["id", "number", "user id", "created at", "updated at"]
271
+ # }
272
+ ```
273
+ With the exportable_association_aliases configured, the phones association has been renamed to "Mobile phones". This new name will appear in the export header when generating exports with this alias for exportable attributes.
274
+
275
+ ### Excluding Specific Exportable Attributes
276
+
277
+ Configure associations to exclude certain attributes from exportable attributes using the `exclude_exportable_attributes(association_attributes)` model method.
278
+
279
+ - Invoke this method below all association declarations.
280
+ - `association_attributes` should follow the pattern `{valid_association_name or model_name: [valid_attributes_to_remove]}`.
281
+ - For removing attributes across all associations and the model itself, use the "all" key as the association_name.
282
+
283
+ ```ruby
284
+ # Example Model with exclude_exportable_attributes
285
+
286
+ # User model with columns: first_name, last_name, created_at, updated_at
287
+ class User < ApplicationRecord
288
+ has_many :phones
289
+
290
+ exclude_exportable_attributes all: [:id], user: [:last_name], phones: [:user_id]
291
+ end
292
+
293
+ # Exportable attributes for the User model will now be
294
+ User.exportable_attributes
295
+ # =>
296
+ # {
297
+ # "User" => ["first name", "created at", "updated at"],
298
+ # "Phones" => ["number", "created at", "updated at"]
299
+ # }
300
+ ```
301
+ In this example, note that:
302
+ - All associations exclude the id attribute.
303
+ - The User model excludes the last_name attribute.
304
+ - The Phones association excludes the user_id attribute.
305
+
306
+
307
+ ### Excluding Specific Exportable Attribute Associations
308
+
309
+ Configure model's exportable attributes to exclude certain associations using the `associations_to_exclude(associations)` model method.
310
+
311
+ - Apply this method below all association declarations.
312
+ - `associations` should follow the pattern `['association_name']`.
23
313
 
24
- ## Contributing
25
- Contribution directions go here.
314
+ ```ruby
315
+ # Example Model with associations_to_exclude
316
+
317
+ # User model with columns: first_name, last_name, created_at, updated_at
318
+ class User < ApplicationRecord
319
+ has_many :phones
320
+
321
+ associations_to_exclude [:phones]
322
+ end
323
+
324
+ # Exportable attributes for the User model will now be
325
+ User.exportable_attributes
326
+ # =>
327
+ # {
328
+ # "User" => ["id", "first name", "last name", "created at", "updated at"]
329
+ # }
330
+ ```
331
+ In this example, the attributes of the phones association are excluded from the exportable attributes of the User model.
332
+
333
+
334
+ ### Adding Custom Attribute to Exportable Attributes
335
+
336
+ Leverage a handy Rails method to transform a model instance method into an attribute, incorporating it into the exportable attributes.
337
+
338
+ ```ruby
339
+ # Example Model with Custom Virtual Attribute
340
+
341
+ # User model with columns: first_name, last_name, created_at, updated_at
342
+ class User < ApplicationRecord
343
+ attribute :total_number_of_phones
344
+
345
+ has_many :phones
346
+
347
+ associations_to_exclude [:phones]
348
+
349
+ def total_number_of_phones
350
+ phones.size
351
+ end
352
+ end
353
+
354
+ # Exportable attributes for the User model will now include
355
+ User.exportable_attributes
356
+ # =>
357
+ # {
358
+ # "User" => ["id", "first name", "last name", "created at", "updated at", "total number of phones"]
359
+ # }
360
+ ```
361
+ In this example, the custom attribute "total number of phones" has been seamlessly integrated into the exportable attributes, showcasing the flexibility of Rails' capabilities.
26
362
 
27
363
  ## License
28
364
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyExports
4
+ module DataAttributesResolver
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ private
9
+
10
+ def value_from_selected_attributes(selected_attributes, record, export_row_template)
11
+ selected_attributes.each_with_object([export_row_template]) do |(association_name, attributes), export_rows|
12
+ objects = objects_for_attribute(association_name, record)
13
+
14
+ attributes.each do |attribute|
15
+ attribute_values = resolve_attributes(attribute, objects)
16
+
17
+ attribute_values.each_with_index do |value, index|
18
+ export_column = export_rows[index] || export_row_template
19
+
20
+ export_rows[index] = export_column.merge(export_header(association_name, attribute) => value)
21
+ end
22
+ end
23
+
24
+ export_row_template.merge!(export_rows.first) if association_name == underscored_self_name
25
+ end
26
+ end
27
+
28
+ def resolve_attributes(attribute, objects)
29
+ return [nil] if objects.empty?
30
+
31
+ objects.map do |object|
32
+ raw_value = object.send(attribute)
33
+ format_value_for_export(object.class, attribute, raw_value)
34
+ end.flatten
35
+ end
36
+
37
+ def format_value_for_export(klass, attribute, value)
38
+ custom = exportable_attribute_formatter_for(klass, attribute)
39
+ return custom.call(value) if custom
40
+ return value if value.nil?
41
+
42
+ type_formatter = type_formatter_for(klass, value)
43
+ type_formatter ? type_formatter.call(value) : value
44
+ end
45
+
46
+ def exportable_attribute_formatter_for(klass, attribute)
47
+ store = exportable_attribute_formatters_store[klass.name.underscore.downcase]
48
+ store && store[attribute.to_s]
49
+ end
50
+
51
+ def type_formatter_for(klass, value)
52
+ klass_key = klass.name.underscore.downcase
53
+ disabled = disabled_type_formatters_store[klass_key] || []
54
+ overrides = overridden_type_formatters_store[klass_key] || {}
55
+
56
+ EasyExports::TYPE_MATCHERS.each do |type, matcher|
57
+ next if disabled.include?(type)
58
+ next unless matcher.call(value)
59
+
60
+ formatter = overrides[type] || EasyExports.type_formatters[type]
61
+ return formatter if formatter
62
+ end
63
+
64
+ nil
65
+ end
66
+
67
+ def objects_for_attribute(association_name, record)
68
+ object = association_name == underscored_self_name ? record : record.send(association_name)
69
+ object.respond_to?(:each) ? object : [object].compact
70
+ end
71
+
72
+ def export_header(association_name, attribute)
73
+ association_alias = associations_aliases_store[underscored_self_name]
74
+ association_alias = association_alias.blank? ? nil : association_alias[association_name]
75
+
76
+ "#{association_alias || association_name}_#{attribute}"
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyExports
4
+ module DataLoader
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ private
9
+
10
+ def fetch_records(ids, selected_attributes, order = nil, batch_size = EasyExports.batch_size)
11
+ validate_association_attributes(selected_attributes, 'to_exported_data')
12
+
13
+ scope = ids.blank? ? all : where(id: ids)
14
+ ordered_ids = apply_export_order(scope, order).pluck(:id)
15
+ associations_to_preload = selected_attributes.keys - [underscored_self_name]
16
+
17
+ ordered_ids.each_slice(batch_size).flat_map do |id_slice|
18
+ batch = apply_export_order(where(id: id_slice), order).to_a
19
+ ActiveRecord::Associations::Preloader.new(records: batch, associations: associations_to_preload).call
20
+ batch
21
+ end
22
+ end
23
+
24
+ def apply_export_order(records, order)
25
+ return records.order(order) if order.present?
26
+ return records.order(created_at: :desc) if column_names.include?('created_at')
27
+
28
+ records
29
+ end
30
+ end
31
+ end
32
+ end
@@ -50,6 +50,4 @@ module EasyExports
50
50
  end
51
51
  end
52
52
  end
53
- end
54
-
55
- ActiveRecord::Base.include EasyExports::ExcludeExportableAttributesConfigurations
53
+ end
@@ -42,4 +42,4 @@ module EasyExports
42
42
  end
43
43
  end
44
44
 
45
- ActiveRecord::Base.include EasyExports::ExportableAssociationAliasesConfigurations
45
+ # ActiveRecord::Base.include EasyExports::ExportableAssociationAliasesConfigurations
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyExports
4
+ module ExportableAttributeFormattersConfigurations
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ private
9
+
10
+ def format_exportable_attribute(attribute, formatter = nil, &block)
11
+ formatter ||= block
12
+ unless formatter.respond_to?(:call)
13
+ raise ArgumentError, 'format_exportable_attribute requires a callable (proc or block)'
14
+ end
15
+
16
+ exportable_attribute_formatters_store[underscored_self_name] ||= {}
17
+ exportable_attribute_formatters_store[underscored_self_name][attribute.to_s] = formatter
18
+ end
19
+
20
+ def disable_exportable_type_formatter(*types)
21
+ validate_known_type_formatter!(types, 'disable_exportable_type_formatter')
22
+
23
+ disabled_type_formatters_store[underscored_self_name] ||= []
24
+ disabled_type_formatters_store[underscored_self_name] |= types.map(&:to_sym)
25
+ end
26
+
27
+ def format_exportable_type(type, formatter = nil, &block)
28
+ formatter ||= block
29
+ unless formatter.respond_to?(:call)
30
+ raise ArgumentError, 'format_exportable_type requires a callable (proc or block)'
31
+ end
32
+ validate_known_type_formatter!([type], 'format_exportable_type')
33
+
34
+ overridden_type_formatters_store[underscored_self_name] ||= {}
35
+ overridden_type_formatters_store[underscored_self_name][type.to_sym] = formatter
36
+ end
37
+
38
+ def validate_known_type_formatter!(types, method)
39
+ invalid = types.map(&:to_sym) - EasyExports::TYPE_MATCHERS.keys
40
+ return if invalid.empty?
41
+
42
+ raise ArgumentError,
43
+ "#{method} unknown type(s): #{invalid.join(', ')}. " \
44
+ "Known types: #{EasyExports::TYPE_MATCHERS.keys.join(', ')}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -20,7 +20,15 @@ module EasyExports
20
20
 
21
21
  def resolve_attributes_for_association(association)
22
22
  association_attributes = association.class_name.constantize.attribute_names
23
- association_attributes - resolve_excluded_exportable_attributes(association.name.to_s.downcase)
23
+ association_attributes -
24
+ resolve_excluded_exportable_attributes(association.name.to_s.downcase) -
25
+ resolve_sensitive_attributes_to_exclude
26
+ end
27
+
28
+ def resolve_sensitive_attributes_to_exclude
29
+ return [] if include_sensitive_exportable_attributes
30
+
31
+ EasyExports.sensitive_attributes.map(&:to_s)
24
32
  end
25
33
 
26
34
  def resolve_associations_names_aliases(association_name)
@@ -32,5 +40,3 @@ module EasyExports
32
40
  end
33
41
  end
34
42
  end
35
-
36
- ActiveRecord::Base.include EasyExports::ExportableAttributeResolvers
@@ -4,13 +4,25 @@ module EasyExports
4
4
  module ExportableAttributes
5
5
  extend ActiveSupport::Concern
6
6
 
7
- class_methods do
7
+ included do
8
8
  cattr_accessor :excluded_exportable_attributes_store, default: {}, instance_writer: false
9
9
  cattr_accessor :associations_aliases_store, default: {}, instance_writer: false
10
10
  cattr_accessor :associations_to_exclude_store, default: {}, instance_writer: false
11
+ cattr_accessor :exportable_attribute_formatters_store, default: {}, instance_writer: false
12
+ cattr_accessor :disabled_type_formatters_store, default: {}, instance_writer: false
13
+ cattr_accessor :overridden_type_formatters_store, default: {}, instance_writer: false
14
+ cattr_accessor :include_sensitive_exportable_attributes, default: false, instance_writer: false
15
+
16
+ include EasyExports::ExportableAssociationAliasesConfigurations
17
+ include EasyExports::ExportableAttributeResolvers
18
+ include EasyExports::ExcludeExportableAttributesConfigurations
19
+ include EasyExports::ExportableAttributeFormattersConfigurations
20
+ include EasyExports::ExportsGenerable
21
+ end
11
22
 
23
+ class_methods do
12
24
  def exportable_attributes
13
- self_with_associations.each_with_object({}) do |association, attributes|
25
+ @exportable_attributes ||= self_with_associations.each_with_object({}) do |association, attributes|
14
26
  association_name = association.name.to_s.downcase
15
27
  next if associations_to_exclude_store[underscored_self_name]&.include? association_name
16
28
 
@@ -21,6 +33,10 @@ module EasyExports
21
33
  end
22
34
  end
23
35
 
36
+ def reset_exportable_attributes_cache!
37
+ @exportable_attributes = nil
38
+ end
39
+
24
40
  private
25
41
 
26
42
  def self_with_associations
@@ -41,7 +57,7 @@ module EasyExports
41
57
  end
42
58
 
43
59
  def humanize_attribute_names(attributes)
44
- attributes.map { |attribute| attribute.humanize(keep_id_suffix: true).downcase }
60
+ attributes.map { |attribute| attribute.humanize(keep_id_suffix: true).downcase }.sort
45
61
  end
46
62
  end
47
63
  end
@@ -4,18 +4,25 @@ module EasyExports
4
4
  module ExportsGenerable
5
5
  extend ActiveSupport::Concern
6
6
 
7
+ included do
8
+ include EasyExports::DataLoader
9
+ include EasyExports::DataAttributesResolver
10
+ end
11
+
7
12
  class_methods do
8
- def generate_exports(fields_to_export = {}, ids = [])
13
+ def generate_exports(fields_to_export = {}, ids = [], order: nil, batch_size: nil)
9
14
  validate_exclude_exportable_attributes_argument(fields_to_export, 'generate_exports')
10
15
 
11
16
  selected_exportable_attributes = revert_transformed_names(fields_to_export)
17
+ selected_exportable_attributes = rearrange_selected_attributes(selected_exportable_attributes)
18
+
12
19
  export_row_template = generate_export_row_template(selected_exportable_attributes)
13
20
 
14
21
  selected_attributes = revert_exportable_attributes_aliases(selected_exportable_attributes)
15
- records = fetch_records(ids, selected_attributes)
22
+ records = fetch_records(ids, selected_attributes, order, batch_size || EasyExports.batch_size)
16
23
 
17
24
  exported_data = records.each_with_object([]) do |record, hash_to_export|
18
- hash_to_export << value_from_selected_attributes(selected_attributes, record, export_row_template)
25
+ hash_to_export << value_from_selected_attributes(selected_attributes, record, export_row_template.dup)
19
26
  end.flatten
20
27
 
21
28
  csv_string = write_exported_data_to_csv(exported_data, export_row_template)
@@ -24,8 +31,12 @@ module EasyExports
24
31
  end
25
32
 
26
33
  def write_exported_data_to_csv(exported_data, export_row_template)
34
+ header_formatter = EasyExports.csv_header_formatter
35
+ keys = export_row_template.keys
36
+ headers = header_formatter ? keys.map { |k| header_formatter.call(k) } : keys
37
+
27
38
  CSV.generate(headers: true) do |csv|
28
- csv << export_row_template.keys
39
+ csv << headers
29
40
 
30
41
  exported_data.each do |data|
31
42
  csv << data.values
@@ -33,90 +44,31 @@ module EasyExports
33
44
  end
34
45
  end
35
46
 
36
- def generate_export_row_template(selected_attributes)
37
- selected_attributes.each_with_object({}) do |(association_name, attributes), export_row|
38
- attributes.each do |attribute|
39
- export_row.merge!("#{association_name}_#{attribute}" => nil)
40
- end
41
- end
42
- end
43
-
44
- def fetch_records(ids, selected_attributes)
45
- validate_association_attributes(selected_attributes, 'to_exported_data')
46
-
47
- records_with_preloaded_associations(ids, selected_attributes)
48
- end
49
-
50
- def association_attributes(association_name)
51
- association_name = if association_name == underscored_self_name
52
- association_name.classify
53
- else
54
- reflect_on_all_associations.find do |association|
55
- association.name.to_s == association_name
56
- end&.class_name
57
- end
47
+ def rearrange_selected_attributes(selected_attributes)
48
+ selected_attributes = selected_attributes.to_a
49
+ self_alias_name = associations_aliases_store[underscored_self_name]&.fetch(underscored_self_name, nil)
50
+ self_name = self_alias_name || underscored_self_name
58
51
 
59
- association_name.constantize.attribute_names
60
- end
61
-
62
- def records_with_preloaded_associations(ids, selected_attributes)
63
- records = ids.blank? ? all : where(id: ids)
64
-
65
- associations_to_preload = selected_attributes.keys
66
- associations_to_preload.delete(underscored_self_name)
52
+ self_exportable_attributes = selected_attributes.find do |selected_attribute|
53
+ selected_attribute.first == self_name
54
+ end
67
55
 
68
- ActiveRecord::Associations::Preloader.new(
69
- records: records,
70
- associations: associations_to_preload
71
- ).call
56
+ if self_exportable_attributes.blank? || selected_attributes[0] == self_exportable_attributes
57
+ return selected_attributes.to_h
58
+ end
72
59
 
73
- records
60
+ selected_attributes.delete(self_exportable_attributes)
61
+ selected_attributes.unshift(self_exportable_attributes).to_h
74
62
  end
75
63
 
76
- def value_from_selected_attributes(selected_attributes, record, export_row_template)
77
- selected_attributes.each_with_object([export_row_template]) do |(association_name, attributes), export_rows|
78
- objects = objects_for_attribute(association_name, record)
79
-
64
+ def generate_export_row_template(selected_attributes)
65
+ selected_attributes.each_with_object({}) do |(association_name, attributes), export_row|
80
66
  attributes.each do |attribute|
81
- attribute_values = resolve_attributes(attribute, objects)
82
-
83
- attribute_values.each_with_index do |value, index|
84
- export_column = export_rows[index] || export_row_template
85
-
86
- export_rows[index] = export_column.merge(export_header(association_name, attribute) => value)
87
- end
67
+ export_row.merge!(export_header(association_name, attribute) => nil)
88
68
  end
89
69
  end
90
70
  end
91
71
 
92
- def export_header(association_name, attribute)
93
- association_alias = associations_aliases_store[underscored_self_name]
94
- association_alias = association_alias.blank? ? nil : association_alias[association_name]
95
-
96
- "#{association_alias || association_name}_#{attribute}"
97
- end
98
-
99
- def resolve_attributes(attribute, objects)
100
- objects.empty? ? [nil] : objects.map { |object| parse_attribute_value(object.send(attribute)) }.flatten
101
- end
102
-
103
- def parse_attribute_value(value)
104
- value_class = value.class
105
-
106
- if value_class.eql?(ActiveSupport::TimeWithZone)
107
- DateTime.parse(value.to_s).strftime('%Y-%m-%d %H:%M:%S')
108
- elsif !value_class.eql?(String)
109
- value
110
- else
111
- value.start_with?('0') ? "'#{value}" : value
112
- end
113
- end
114
-
115
- def objects_for_attribute(association_name, record)
116
- object = association_name == underscored_self_name ? record : record.send(association_name)
117
- object.respond_to?(:each) ? object : [object].compact
118
- end
119
-
120
72
  def revert_exportable_attributes_aliases(attributes_with_aliases)
121
73
  attributes_with_aliases.transform_keys { |key| reversed_associations_name_aliases[key] || key }
122
74
  end
@@ -138,5 +90,3 @@ module EasyExports
138
90
  end
139
91
  end
140
92
  end
141
-
142
- ActiveRecord::Base.include EasyExports::ExportsGenerable
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EasyExports
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/easy_exports.rb CHANGED
@@ -1,15 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/core_ext/module/attribute_accessors'
4
+
3
5
  require 'easy_exports/version'
4
6
  require 'easy_exports/railtie'
5
- require 'easy_exports/exportable_attributes'
6
7
  require 'easy_exports/exportable_attribute_resolvers'
7
8
  require 'easy_exports/exclude_associations_configurations'
8
9
  require 'easy_exports/exclude_exportable_attributes_configurations'
9
10
  require 'easy_exports/exportable_association_aliases_configurations'
10
- require 'easy_exports/exports_generable'
11
+ require 'easy_exports/exportable_attribute_formatters_configurations'
11
12
  require 'easy_exports/export'
13
+ require 'easy_exports/data_loader'
14
+ require 'easy_exports/data_attributes_resolver'
15
+ require 'easy_exports/exports_generable'
16
+ require 'easy_exports/exportable_attributes'
12
17
 
13
18
  module EasyExports
14
- # Your code goes here...
19
+ DEFAULT_BATCH_SIZE = 1_000
20
+ DEFAULT_SENSITIVE_ATTRIBUTES = %w[
21
+ password_digest
22
+ encrypted_password
23
+ remember_token
24
+ reset_password_token
25
+ confirmation_token
26
+ session_token
27
+ api_key
28
+ secret_token
29
+ ].freeze
30
+
31
+ TYPE_MATCHERS = {
32
+ boolean: ->(v) { v == true || v == false },
33
+ datetime: ->(v) { v.is_a?(Date) || v.is_a?(Time) || v.is_a?(DateTime) || v.is_a?(ActiveSupport::TimeWithZone) },
34
+ leading_zero_string: ->(v) { v.is_a?(String) && v.start_with?('0') },
35
+ string: ->(v) { v.is_a?(String) && !v.include?('@') }
36
+ }.freeze
37
+
38
+ TYPE_FORMATTERS = {
39
+ boolean: ->(v) { v ? 'Yes' : 'No' },
40
+ datetime: ->(v) { v.strftime('%A, %B %-d, %Y %H:%M') },
41
+ leading_zero_string: ->(v) { "'#{v}" }
42
+ }.freeze
43
+
44
+ DEFAULT_CSV_HEADER_FORMATTER = ->(key) { key.to_s.humanize.titleize }
45
+
46
+ mattr_accessor :batch_size, default: DEFAULT_BATCH_SIZE
47
+ mattr_accessor :sensitive_attributes, default: DEFAULT_SENSITIVE_ATTRIBUTES.dup
48
+ mattr_accessor :type_formatters, default: TYPE_FORMATTERS.dup
49
+ mattr_accessor :csv_header_formatter, default: DEFAULT_CSV_HEADER_FORMATTER
50
+
51
+ def self.configure
52
+ yield self
53
+ end
15
54
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: easy_exports
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dapilah Sydney
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2023-08-16 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -106,10 +105,13 @@ files:
106
105
  - README.md
107
106
  - Rakefile
108
107
  - lib/easy_exports.rb
108
+ - lib/easy_exports/data_attributes_resolver.rb
109
+ - lib/easy_exports/data_loader.rb
109
110
  - lib/easy_exports/exclude_associations_configurations.rb
110
111
  - lib/easy_exports/exclude_exportable_attributes_configurations.rb
111
112
  - lib/easy_exports/export.rb
112
113
  - lib/easy_exports/exportable_association_aliases_configurations.rb
114
+ - lib/easy_exports/exportable_attribute_formatters_configurations.rb
113
115
  - lib/easy_exports/exportable_attribute_resolvers.rb
114
116
  - lib/easy_exports/exportable_attributes.rb
115
117
  - lib/easy_exports/exports_generable.rb
@@ -124,7 +126,6 @@ metadata:
124
126
  homepage_uri: https://github.com/SydDaps/easy_exports
125
127
  source_code_uri: https://github.com/SydDaps/easy_exports
126
128
  changelog_uri: https://rubygems.org/
127
- post_install_message:
128
129
  rdoc_options: []
129
130
  require_paths:
130
131
  - lib
@@ -139,8 +140,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
139
140
  - !ruby/object:Gem::Version
140
141
  version: '0'
141
142
  requirements: []
142
- rubygems_version: 3.2.3
143
- signing_key:
143
+ rubygems_version: 3.7.2
144
144
  specification_version: 4
145
145
  summary: Streamline data retrieval from Rails models
146
146
  test_files: []