easy_exports 0.1.2 → 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: ed1cc375db87028330d34ded26b7e247a1ff4382267254118e06b994d5ae66d6
4
- data.tar.gz: 4529a792f705691f04ea2b707a00503866d324ca2388bb5587ffd61563a8f98c
3
+ metadata.gz: 99d64117b05b63ec1f0cd4ef5b8b6a7ec3d653087678d9b4831edb87c8dd61c4
4
+ data.tar.gz: 884619c1077d8ff642ceb1466f4c9ffcbb50bac95277b5bf7765303f53d666f3
5
5
  SHA512:
6
- metadata.gz: c5ca2b24a5a23f685d04dfd020cff2dea3bab1d550f40c1467cfbc04608cfaf701bd69fd6eb91652a1f4ede40f8608fee8f87ac4bd22ff3742155b7238f19eab
7
- data.tar.gz: 565ddc57bd713f6097aad5717ddb9c95d39fc18d7e3a5db2a81b5d489098c0fdf17723b7b83d9b018f25ef88c6ecb7edebfc7c364fb6aa1b5add5ae7db80ad5c
6
+ metadata.gz: e843ce5e1aa5edab56616992a16d989952c52343a854619d0fb46715617af59a2e4ca641d69bb8dc243d2aea63b7bdae42a164e2e39e3ce9784e471ff04f3054
7
+ data.tar.gz: 4915abc76083e2e755e954699b788e4d5debaef66cd764c42af7d85eb2dc56dafff68589ca81c38a71fbdb64962c8a891c1291781f8591af20702f4be8f463a4
data/README.md CHANGED
@@ -58,11 +58,12 @@ Retrieve exportable attributes using the `exportable_attributes` method. This me
58
58
 
59
59
  ### Generating Exports from Exportable Attributes
60
60
 
61
- To generate exports, use the `generate_exports(exportable_attributes, ids)` method.
61
+ To generate exports, use the `generate_exports(exportable_attributes, ids, order:)` method.
62
62
 
63
63
  - The `exportable_attributes` argument specifies the chosen attributes from the exportable attributes list.
64
64
  - The `ids` argument is optional; provide IDs to export data for specific records.
65
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.
66
67
 
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.
68
69
 
@@ -107,6 +108,142 @@ Exported CSV showcases data for:
107
108
  - The main CSV header includes association names and attribute names.
108
109
 
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
+
110
247
  ### Exportable Attributes Aliases
111
248
 
112
249
  Configure an alternative association name for exportable attributes using the `exportable_association_aliases(aliases)` model method.
@@ -26,15 +26,42 @@ module EasyExports
26
26
  end
27
27
 
28
28
  def resolve_attributes(attribute, objects)
29
- objects.empty? ? [nil] : objects.map { |object| parse_attribute_value(object.send(attribute)) }.flatten
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
30
44
  end
31
45
 
32
- def parse_attribute_value(value)
33
- return DateTime.parse(value.to_s).strftime('%Y-%m-%d %H:%M:%S') if value.is_a?(ActiveSupport::TimeWithZone)
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] || {}
34
55
 
35
- return "'#{value}" if value.is_a?(String) && value&.start_with?('0')
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
36
63
 
37
- value
64
+ nil
38
65
  end
39
66
 
40
67
  def objects_for_attribute(association_name, record)
@@ -7,34 +7,23 @@ module EasyExports
7
7
  class_methods do
8
8
  private
9
9
 
10
- def fetch_records(ids, selected_attributes)
10
+ def fetch_records(ids, selected_attributes, order = nil, batch_size = EasyExports.batch_size)
11
11
  validate_association_attributes(selected_attributes, 'to_exported_data')
12
12
 
13
- records_with_preloaded_associations(ids, selected_attributes)
14
- end
15
-
16
- def association_attributes(association_name)
17
- association_name = if association_name == underscored_self_name
18
- association_name.classify
19
- else
20
- reflect_on_all_associations.find do |association|
21
- association.name.to_s == association_name
22
- end&.class_name
23
- end
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]
24
16
 
25
- association_name.constantize.attribute_names
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
26
22
  end
27
23
 
28
- def records_with_preloaded_associations(ids, selected_attributes)
29
- records = ids.blank? ? all : where(id: ids)
30
-
31
- associations_to_preload = selected_attributes.keys
32
- associations_to_preload.delete(underscored_self_name)
33
-
34
- ActiveRecord::Associations::Preloader.new(
35
- records: records,
36
- associations: associations_to_preload
37
- ).call
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')
38
27
 
39
28
  records
40
29
  end
@@ -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)
@@ -8,17 +8,21 @@ module EasyExports
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
- #TODO: cache attributes(make option)
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
12
15
 
13
16
  include EasyExports::ExportableAssociationAliasesConfigurations
14
17
  include EasyExports::ExportableAttributeResolvers
15
18
  include EasyExports::ExcludeExportableAttributesConfigurations
19
+ include EasyExports::ExportableAttributeFormattersConfigurations
16
20
  include EasyExports::ExportsGenerable
17
21
  end
18
22
 
19
23
  class_methods do
20
24
  def exportable_attributes
21
- self_with_associations.each_with_object({}) do |association, attributes|
25
+ @exportable_attributes ||= self_with_associations.each_with_object({}) do |association, attributes|
22
26
  association_name = association.name.to_s.downcase
23
27
  next if associations_to_exclude_store[underscored_self_name]&.include? association_name
24
28
 
@@ -29,6 +33,10 @@ module EasyExports
29
33
  end
30
34
  end
31
35
 
36
+ def reset_exportable_attributes_cache!
37
+ @exportable_attributes = nil
38
+ end
39
+
32
40
  private
33
41
 
34
42
  def self_with_associations
@@ -10,7 +10,7 @@ module EasyExports
10
10
  end
11
11
 
12
12
  class_methods do
13
- def generate_exports(fields_to_export = {}, ids = [])
13
+ def generate_exports(fields_to_export = {}, ids = [], order: nil, batch_size: nil)
14
14
  validate_exclude_exportable_attributes_argument(fields_to_export, 'generate_exports')
15
15
 
16
16
  selected_exportable_attributes = revert_transformed_names(fields_to_export)
@@ -19,7 +19,7 @@ module EasyExports
19
19
  export_row_template = generate_export_row_template(selected_exportable_attributes)
20
20
 
21
21
  selected_attributes = revert_exportable_attributes_aliases(selected_exportable_attributes)
22
- records = fetch_records(ids, selected_attributes)
22
+ records = fetch_records(ids, selected_attributes, order, batch_size || EasyExports.batch_size)
23
23
 
24
24
  exported_data = records.each_with_object([]) do |record, hash_to_export|
25
25
  hash_to_export << value_from_selected_attributes(selected_attributes, record, export_row_template.dup)
@@ -31,8 +31,12 @@ module EasyExports
31
31
  end
32
32
 
33
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
+
34
38
  CSV.generate(headers: true) do |csv|
35
- csv << export_row_template.keys
39
+ csv << headers
36
40
 
37
41
  exported_data.each do |data|
38
42
  csv << data.values
@@ -60,7 +64,7 @@ module EasyExports
60
64
  def generate_export_row_template(selected_attributes)
61
65
  selected_attributes.each_with_object({}) do |(association_name, attributes), export_row|
62
66
  attributes.each do |attribute|
63
- export_row.merge!("#{association_name}_#{attribute}" => nil)
67
+ export_row.merge!(export_header(association_name, attribute) => nil)
64
68
  end
65
69
  end
66
70
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EasyExports
4
- VERSION = '0.1.2'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/easy_exports.rb CHANGED
@@ -1,13 +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
7
  require 'easy_exports/exportable_attribute_resolvers'
6
8
  require 'easy_exports/exclude_associations_configurations'
7
9
  require 'easy_exports/exclude_exportable_attributes_configurations'
8
10
  require 'easy_exports/exportable_association_aliases_configurations'
11
+ require 'easy_exports/exportable_attribute_formatters_configurations'
9
12
  require 'easy_exports/export'
10
13
  require 'easy_exports/data_loader'
11
14
  require 'easy_exports/data_attributes_resolver'
12
15
  require 'easy_exports/exports_generable'
13
16
  require 'easy_exports/exportable_attributes'
17
+
18
+ module EasyExports
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
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.2
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-24 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
@@ -112,6 +111,7 @@ files:
112
111
  - lib/easy_exports/exclude_exportable_attributes_configurations.rb
113
112
  - lib/easy_exports/export.rb
114
113
  - lib/easy_exports/exportable_association_aliases_configurations.rb
114
+ - lib/easy_exports/exportable_attribute_formatters_configurations.rb
115
115
  - lib/easy_exports/exportable_attribute_resolvers.rb
116
116
  - lib/easy_exports/exportable_attributes.rb
117
117
  - lib/easy_exports/exports_generable.rb
@@ -126,7 +126,6 @@ metadata:
126
126
  homepage_uri: https://github.com/SydDaps/easy_exports
127
127
  source_code_uri: https://github.com/SydDaps/easy_exports
128
128
  changelog_uri: https://rubygems.org/
129
- post_install_message:
130
129
  rdoc_options: []
131
130
  require_paths:
132
131
  - lib
@@ -141,8 +140,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
140
  - !ruby/object:Gem::Version
142
141
  version: '0'
143
142
  requirements: []
144
- rubygems_version: 3.2.3
145
- signing_key:
143
+ rubygems_version: 3.7.2
146
144
  specification_version: 4
147
145
  summary: Streamline data retrieval from Rails models
148
146
  test_files: []